Skip to main content

pylon_core/diff/
mod.rs

1//
2// This source file is part of the Pylon open source project.
3//
4// Copyright (c) 2026 Jaldis B.V.
5//
6// Licensed under the MIT OR Apache-2.0 license (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10//     https://opensource.org/licenses/MIT
11//     https://www.apache.org/licenses/LICENSE-2.0
12//
13// Unless required by applicable law or agreed to in writing, software
14// distributed under the License is distributed on an "AS IS" BASIS,
15// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16// See the License for the specific language governing permissions and
17// limitations under the License.
18//
19
20//! Schema diff engine: given a target SchemaDescriptor and the live database
21//! state (introspected via pg_catalog), produce ordered DDL SQL statements
22//! to bring the database in sync with the target.
23//!
24//! Used by `pylon migration watch` (apply directly) and `pylon migration create`
25//! (render as a migration file body). Pure computation — no I/O.
26
27use std::collections::{HashMap, HashSet};
28
29use serde::{Deserialize, Serialize};
30
31use crate::schema::{SchemaDescriptor, SearchBackend, TypeDescriptor, resolved_pg_type};
32
33// ── Live database state ────────────────────────────────────────────────────────
34
35/// Snapshot of the live PostgreSQL database, built by Python from pg_catalog queries.
36#[derive(Debug, Default, Clone, Serialize, Deserialize)]
37pub struct DbState {
38    /// User-managed schema names (excludes _pylon, public, pg_* etc.).
39    #[serde(default)]
40    pub schemas: Vec<String>,
41    #[serde(default)]
42    pub tables: Vec<DbTable>,
43    #[serde(default)]
44    pub enums: Vec<DbEnum>,
45    #[serde(default)]
46    pub domains: Vec<DbDomain>,
47    #[serde(default)]
48    pub sequences: Vec<DbSequence>,
49    #[serde(default)]
50    pub views: Vec<DbView>,
51    #[serde(default)]
52    pub functions: Vec<DbFunction>,
53    /// Installed Postgres extension names (e.g. `vector`) — used to decide
54    /// whether a `CREATE EXTENSION` needs to be added to a migration; see
55    /// `required_extensions`/`missing_extension_ddl`.
56    #[serde(default)]
57    pub extensions: Vec<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct DbTable {
62    pub schema: String,
63    pub name: String,
64    pub columns: Vec<DbColumn>,
65    pub foreign_keys: Vec<DbForeignKey>,
66    pub indexes: Vec<DbIndex>,
67    pub checks: Vec<DbCheck>,
68    #[serde(default)]
69    pub triggers: Vec<String>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct DbColumn {
74    pub name: String,
75    pub pg_type: String,
76    pub nullable: bool,
77    pub is_generated: bool,
78    #[serde(default)]
79    pub column_default: Option<String>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct DbForeignKey {
84    pub constraint_name: String,
85    pub local_column: String,
86    pub ref_schema: String,
87    pub ref_table: String,
88}
89
90/// Postgres refuses an identifier over 63 bytes, and a composite exclusive on
91/// a long type name easily passes that. Keep the readable head and settle the
92/// tail with a hash, so the name stays stable across runs.
93fn capped_index_name(name: String) -> String {
94    const MAX: usize = 63;
95    if name.len() <= MAX {
96        return name;
97    }
98    let hash = crate::export::fnv(&[&name]);
99    let head: String = name.chars().take(MAX - 9).collect();
100    format!("{head}_{}", &hash[..8])
101}
102
103/// The column a pointer is stored in: a link keeps its key in `{name}_id`,
104/// a property in a column of its own name.
105fn pointer_column(td: &TypeDescriptor, pointer: &str) -> String {
106    if td.links.iter().any(|l| l.name == pointer && !l.is_junction_backed()) {
107        format!("{pointer}_id")
108    } else {
109        pointer.to_string()
110    }
111}
112
113/// Whether a pointer reaches this type from an interface it implements. Such a
114/// pointer is flattened onto every implementor, so uniqueness declared on it
115/// has to hold across all of their tables at once -- not per table.
116fn inherited_from_an_interface(schema: &SchemaDescriptor, td: &TypeDescriptor, pointer: &str) -> bool {
117    td.interfaces.iter().any(|iface| {
118        schema
119            .types
120            .iter()
121            .filter(|t| format!("{}::{}", t.module, t.name) == *iface)
122            .any(|t| {
123                t.properties.iter().any(|p| p.name == pointer)
124                    || t.links.iter().any(|l| l.name == pointer)
125                    || t.multilinks.iter().any(|ml| ml.name == pointer)
126            })
127    })
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct DbIndex {
132    pub name: String,
133    pub is_unique: bool,
134    pub method: String,
135    /// The columns the index is keyed on, in order. Postgres auto-names an
136    /// index created without one, so a name is no basis for deciding whether
137    /// the database already has what the schema asks for -- the columns are.
138    /// Empty for an index over an expression, which is matched by name.
139    #[serde(default)]
140    pub columns: Vec<String>,
141    /// The `WHERE` of a partial index, already compiled to SQL. An exclusive
142    /// declared `unless=` covers only the rows the condition excludes, and an
143    /// index built without it would refuse rows the constraint allows.
144    #[serde(default)]
145    pub predicate: Option<String>,
146    /// The whole `(...)` key as SQL, for an index whose key is not a plain
147    /// column list -- a computed pointer, or an expression index. `columns`
148    /// stays empty then, and this is what decides whether it already exists.
149    #[serde(default)]
150    pub key: Option<String>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct DbCheck {
155    pub constraint_name: String,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct DbEnum {
160    pub schema: String,
161    pub name: String,
162    pub members: Vec<String>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct DbDomain {
167    pub schema: String,
168    pub name: String,
169    /// Names of the CHECK constraints the domain carries.
170    #[serde(default)]
171    pub checks: Vec<String>,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct DbSequence {
176    pub schema: String,
177    pub name: String,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct DbView {
182    pub schema: String,
183    pub name: String,
184    /// SHA-256 (first 16 hex chars) of the rendered DDL — detects definition changes.
185    pub body_hash: String,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct DbFunction {
190    pub schema: String,
191    pub name: String,
192    /// SHA-256 (first 16 hex chars) of the rendered DDL — detects body changes.
193    pub body_hash: String,
194}
195
196// ── Schema → DbState projection ────────────────────────────────────────────────
197
198/// Convert a compiled `SchemaDescriptor` into the equivalent `DbState` snapshot.
199///
200/// This produces exactly what `introspect_db_state` would return after the schema
201/// is fully applied — used to persist a baseline alongside each migration record
202/// so that subsequent `pylon migration create` calls can diff without introspecting
203/// the live database.
204pub fn schema_to_db_state(schema: &SchemaDescriptor) -> DbState {
205    use std::collections::BTreeSet;
206
207    let type_map: HashMap<String, (&str, &str)> = schema
208        .types
209        .iter()
210        .map(|t| {
211            (
212                format!("{}::{}", t.module, t.name),
213                (t.module.as_str(), t.table.as_str()),
214            )
215        })
216        .collect();
217
218    // Collect all module names that contribute a Postgres schema.
219    let mut schema_set: BTreeSet<String> = BTreeSet::new();
220    for t in &schema.types {
221        schema_set.insert(t.module.clone());
222    }
223    for e in &schema.enums {
224        schema_set.insert(e.module.clone());
225    }
226    for s in &schema.scalars {
227        schema_set.insert(s.module.clone());
228    }
229
230    let schemas: Vec<String> = schema_set.into_iter().collect();
231
232    // Enums
233    let enums: Vec<DbEnum> = schema
234        .enums
235        .iter()
236        .map(|e| DbEnum {
237            schema: e.module.clone(),
238            name: e.name.clone(),
239            members: e.members.clone(),
240        })
241        .collect();
242
243    // Domains (custom scalars)
244    let domains: Vec<DbDomain> = schema
245        .scalars
246        .iter()
247        .map(|s| DbDomain {
248            schema: s.module.clone(),
249            name: s.name.clone(),
250            checks: crate::export::scalar_check_constraints(schema)
251                .into_iter()
252                .filter(|(module, name, _, _)| *module == s.module && *name == s.name)
253                .map(|(_, _, cname, _)| cname)
254                .collect(),
255        })
256        .collect();
257
258    // Sequences (sequence scalars only)
259    let sequences: Vec<DbSequence> = schema
260        .scalars
261        .iter()
262        .filter(|s| s.is_sequence)
263        .map(|s| DbSequence {
264            schema: s.module.clone(),
265            name: format!("{}_seq", s.name),
266        })
267        .collect();
268
269    let expected_trigger_names = expected_triggers(schema, &type_map);
270    let mut expected_checks: HashMap<(String, String), Vec<String>> = HashMap::new();
271    for (module, table, cname, _) in crate::export::check_constraints(schema).unwrap_or_default() {
272        expected_checks.entry((module, table)).or_default().push(cname);
273    }
274    let mut tables: Vec<DbTable> = Vec::new();
275
276    for td in &schema.types {
277        if td.abstract_ || td.junction {
278            continue;
279        }
280
281        // Columns: properties + link FK stubs + vector/search generated columns
282        let mut columns: Vec<DbColumn> = Vec::new();
283        for p in &td.properties {
284            columns.push(DbColumn {
285                name: p.name.clone(),
286                pg_type: col_type_str(p).to_string(),
287                nullable: p.nullable,
288                is_generated: false,
289                column_default: resolve_default(p, schema),
290            });
291        }
292        for l in &td.links {
293            // A junction-backed link has no `{name}_id` column — it's
294            // stored via a junction table instead (below), same as a
295            // multi-link.
296            if l.is_junction_backed() {
297                continue;
298            }
299            columns.push(DbColumn {
300                name: format!("{}_id", l.name),
301                pg_type: "uuid".to_string(),
302                nullable: l.nullable,
303                is_generated: false,
304                column_default: resolve_link_default(l, schema),
305            });
306        }
307        // Generated columns for vector indexes
308        for vi in &td.vector_indexes {
309            let col = vi.column_name();
310            if !columns.iter().any(|c| c.name == col) {
311                columns.push(DbColumn {
312                    name: col,
313                    pg_type: format!("vector({})", vi.dimensions),
314                    nullable: true,
315                    is_generated: false,
316                    column_default: None,
317                });
318            }
319        }
320        // Generated columns for search indexes (tsvector)
321        for si in &td.search_indexes {
322            if si.backend != SearchBackend::Postgres {
323                continue;
324            }
325            let col = si.column_name();
326            if !columns.iter().any(|c| c.name == col) {
327                columns.push(DbColumn {
328                    name: col,
329                    pg_type: "tsvector".to_string(),
330                    nullable: true,
331                    is_generated: true,
332                    column_default: None,
333                });
334            }
335        }
336
337        // FK constraints: one per single link
338        let mut foreign_keys: Vec<DbForeignKey> = Vec::new();
339        for l in &td.links {
340            if l.is_junction_backed() {
341                continue;
342            }
343            let cname = format!("{}_{}_fkey", td.table, l.name);
344            if let Some((tgt_schema, tgt_table)) = type_map.get(&l.target) {
345                foreign_keys.push(DbForeignKey {
346                    constraint_name: cname,
347                    local_column: format!("{}_id", l.name),
348                    ref_schema: tgt_schema.to_string(),
349                    ref_table: tgt_table.to_string(),
350                });
351            }
352        }
353
354        // Unique indexes from exclusive properties and links (Postgres auto-names them).
355        let mut indexes: Vec<DbIndex> = Vec::new();
356        for p in &td.properties {
357            // An exclusive inherited from an interface spans every
358            // implementor's table, so a per-table unique index would only
359            // enforce it within one of them; those are carried by the shared
360            // constraint triggers `interface_exclusive_trigger_infos` emits.
361            if p.is_exclusive && !p.is_pk && !inherited_from_an_interface(schema, td, &p.name) {
362                indexes.push(DbIndex {
363                    name: format!("{}_{}_key", td.table, p.name),
364                    is_unique: true,
365                    method: "btree".to_string(),
366                    columns: vec![p.name.clone()],
367                    predicate: None,
368                    key: None,
369                });
370            }
371        }
372        for l in &td.links {
373            // A junction-backed exclusive link's uniqueness is a
374            // `UNIQUE (target)` table constraint on its junction table
375            // (below), not a separate index on this table.
376            if l.is_exclusive && !l.is_junction_backed() && !inherited_from_an_interface(schema, td, &l.name) {
377                indexes.push(DbIndex {
378                    name: format!("{}_{}_id_key", td.table, l.name),
379                    is_unique: true,
380                    method: "btree".to_string(),
381                    columns: vec![format!("{}_id", l.name)],
382                    predicate: None,
383                    key: None,
384                });
385            }
386        }
387        for (i, constraint) in td.constraints.iter().enumerate() {
388            use crate::schema::TypeConstraint;
389            if let TypeConstraint::Exclusive {
390                pointers: fields,
391                unless,
392            } = constraint
393            {
394                // Postgres auto-names these; mirror the convention.
395                let idx_name = format!("{}_{}_{}_key", td.table, fields.join("_"), i);
396                indexes.push(DbIndex {
397                    name: idx_name,
398                    is_unique: true,
399                    method: "btree".to_string(),
400                    columns: fields.iter().map(|f| pointer_column(td, f)).collect(),
401                    predicate: unless.as_deref().and_then(|u| {
402                        let qualified = format!("{}::{}", td.module, td.name);
403                        crate::ir::compile_constraint_expr(u, &qualified, schema)
404                            .ok()
405                            .map(|c| format!(" WHERE NOT ({c})"))
406                    }),
407                    key: None,
408                });
409            }
410        }
411        // Plain indexes (Postgres auto-names these too).
412        for (i, idx) in td.indexes.iter().enumerate() {
413            let name = if idx.expression.is_some() {
414                format!("{}__expr{}_idx", td.table, i)
415            } else {
416                format!("{}__{}_idx", td.table, idx.pointers.join("_"))
417            };
418            // The key is built by the same helper the export uses, so a
419            // pointer that is a link or a computed lands on the same SQL in
420            // both paths rather than only in one.
421            let Ok((body, predicate)) = crate::export::index_body_and_predicate(
422                td,
423                &idx.pointers,
424                idx.expression.as_deref(),
425                idx.unless.as_deref(),
426                schema,
427            ) else {
428                continue;
429            };
430            indexes.push(DbIndex {
431                name,
432                is_unique: idx.unique,
433                method: "btree".to_string(),
434                columns: vec![],
435                key: Some(body),
436                predicate: (!predicate.is_empty()).then_some(predicate),
437            });
438        }
439        // Vector HNSW indexes
440        for vi in &td.vector_indexes {
441            let idx_name = match &vi.index_name {
442                None => format!("{}__vector__", td.table),
443                Some(n) => format!("{}__vector_{}__", td.table, n),
444            };
445            indexes.push(DbIndex {
446                name: idx_name,
447                is_unique: false,
448                method: "hnsw".to_string(),
449                columns: vec![],
450                predicate: None,
451                key: None,
452            });
453        }
454        // Search GIN indexes
455        for si in &td.search_indexes {
456            if si.backend != SearchBackend::Postgres {
457                continue;
458            }
459            let idx_name = match &si.index_name {
460                None => format!("{}__search__", td.table),
461                Some(n) => format!("{}__search_{}__", td.table, n),
462            };
463            indexes.push(DbIndex {
464                name: idx_name,
465                is_unique: false,
466                method: "gin".to_string(),
467                columns: vec![],
468                predicate: None,
469                key: None,
470            });
471        }
472
473        // CHECK constraints, named exactly as `export::check_constraints` names
474        // them — an approximation here would never match what was emitted, so
475        // every diff would try to add them again.
476        let checks: Vec<DbCheck> = expected_checks
477            .get(&(td.module.clone(), td.table.clone()))
478            .cloned()
479            .unwrap_or_default()
480            .into_iter()
481            .map(|constraint_name| DbCheck { constraint_name })
482            .collect();
483
484        let triggers: Vec<String> = expected_trigger_names
485            .get(&(td.module.clone(), td.table.clone()))
486            .cloned()
487            .unwrap_or_default()
488            .into_iter()
489            .collect();
490        tables.push(DbTable {
491            schema: td.module.clone(),
492            name: td.table.clone(),
493            columns,
494            foreign_keys,
495            indexes,
496            checks,
497            triggers,
498        });
499
500        // Junction tables for multi-links
501        for ml in &td.multilinks {
502            tables.push(build_junction_db_table(
503                schema,
504                &type_map,
505                td,
506                &ml.name,
507                &ml.target,
508                ml.through.as_deref(),
509                ml.is_exclusive,
510                &expected_trigger_names,
511            ));
512        }
513        // Junction tables for junction-backed single links — same shape
514        // (source/target + through columns); cardinality is enforced via
515        // an inline PRIMARY KEY table constraint the diff engine doesn't
516        // track as a separate index (mirroring how a plain multi-link's own
517        // inline `PRIMARY KEY (source, target)` isn't tracked as an index
518        // here either).
519        for l in &td.links {
520            if !l.is_junction_backed() {
521                continue;
522            }
523            tables.push(build_junction_db_table(
524                schema,
525                &type_map,
526                td,
527                &l.name,
528                &l.target,
529                l.through.as_deref(),
530                l.is_exclusive,
531                &expected_trigger_names,
532            ));
533        }
534    }
535
536    // Views (interface types), both the object views and the per-multi-link
537    // junction views. Leaving the junction ones out here made them absent from
538    // the recorded state, so every subsequent `migration create` believed they
539    // were missing and re-emitted all of them.
540    let views: Vec<DbView> = crate::export::interface_view_ddl_with_names(schema)
541        .into_iter()
542        .chain(crate::export::interface_junction_view_ddl_with_names(schema))
543        .map(|(module, name, ddl)| DbView {
544            schema: module,
545            name,
546            body_hash: ddl_hash(&ddl),
547        })
548        .collect();
549
550    // User-defined functions
551    let functions: Vec<DbFunction> = crate::export::function_ddl_with_names(schema)
552        .unwrap_or_default()
553        .into_iter()
554        .map(|(module, name, ddl)| DbFunction {
555            schema: module,
556            name,
557            body_hash: ddl_hash(&ddl),
558        })
559        .collect();
560
561    let extensions: Vec<String> = required_extensions(schema).iter().map(|s| s.to_string()).collect();
562
563    // A link into a polymorphic type has no foreign key; its triggers stand in.
564    let polymorphic_tables: HashSet<(&str, &str)> = {
565        let polymorphic = crate::export::polymorphic_types(schema);
566        schema
567            .types
568            .iter()
569            .filter(|t| polymorphic.contains(&format!("{}::{}", t.module, t.name)))
570            .map(|t| (t.module.as_str(), t.table.as_str()))
571            .collect()
572    };
573    for table in &mut tables {
574        table.foreign_keys.retain(|fk| {
575            fk.local_column == "source"
576                || !polymorphic_tables.contains(&(fk.ref_schema.as_str(), fk.ref_table.as_str()))
577        });
578    }
579
580    DbState {
581        schemas,
582        tables,
583        enums,
584        domains,
585        sequences,
586        views,
587        functions,
588        extensions,
589    }
590}
591
592/// Postgres extensions `target` needs in order for its own DDL to apply
593/// cleanly — currently just `vector` (pgvector), needed the moment any
594/// type declares a vector index. Extending this to a future
595/// extension-dependent feature is just adding another check here; the
596/// caller-facing surface (`missing_extension_ddl`) doesn't change.
597pub fn required_extensions(target: &SchemaDescriptor) -> Vec<&'static str> {
598    let mut out = Vec::new();
599    if target.types.iter().any(|t| !t.vector_indexes.is_empty()) {
600        out.push("vector");
601    }
602    if target.types.iter().any(|t| t.partition.is_some()) {
603        out.push("pg_partman");
604    }
605    out
606}
607
608/// `CREATE EXTENSION IF NOT EXISTS` statements for every extension
609/// `target` requires that isn't already present in `current` — meant to be
610/// prepended to an assembled migration/`watch` sync unconditionally,
611/// outside the interactive per-step confirmation flow: enabling a
612/// required extension isn't a design decision to confirm or reject, it's
613/// a hard prerequisite the rest of the DDL can't succeed without.
614pub fn missing_extension_ddl(target: &SchemaDescriptor, current: &DbState) -> Vec<String> {
615    required_extensions(target)
616        .into_iter()
617        .filter(|ext| !current.extensions.iter().any(|e| e == ext))
618        .map(|ext| format!("CREATE EXTENSION IF NOT EXISTS \"{ext}\";"))
619        .collect()
620}
621
622/// Builds the expected `DbTable` for one junction table — shared by a
623/// multi-link and a junction-backed single link, since both store
624/// (source, target, through-type properties) identically; only the
625/// caller-supplied `PRIMARY KEY` (not tracked here as a separate index — see
626/// `schema_to_db_state`) differs between the two. An exclusive one is also
627/// unique on `target`.
628#[allow(clippy::too_many_arguments)]
629fn build_junction_db_table(
630    schema: &SchemaDescriptor,
631    type_map: &HashMap<String, (&str, &str)>,
632    td: &TypeDescriptor,
633    name: &str,
634    target: &str,
635    through: Option<&str>,
636    exclusive: bool,
637    expected_trigger_names: &HashMap<(String, String), HashSet<String>>,
638) -> DbTable {
639    let jt_name = format!("{}.{}", td.table, name);
640    let mut jt_columns = vec![
641        DbColumn {
642            name: "source".to_string(),
643            pg_type: "uuid".to_string(),
644            nullable: false,
645            is_generated: false,
646            column_default: None,
647        },
648        DbColumn {
649            name: "target".to_string(),
650            pg_type: "uuid".to_string(),
651            nullable: false,
652            is_generated: false,
653            column_default: None,
654        },
655    ];
656
657    // Extra columns from the through junction type
658    if let Some(through_qname) = through
659        && let Some(through_td) = schema
660            .types
661            .iter()
662            .find(|t| format!("{}::{}", t.module, t.name) == *through_qname && t.junction)
663    {
664        for p in &through_td.properties {
665            if p.name == "id" {
666                continue;
667            }
668            let pg_type = col_type_str(p).to_string();
669            jt_columns.push(DbColumn {
670                name: p.name.clone(),
671                pg_type,
672                nullable: p.nullable,
673                is_generated: false,
674                column_default: p.default_sql.clone(),
675            });
676        }
677    }
678
679    let mut jt_fks = Vec::new();
680    let src_fk_name = format!("{}_{}_source_fkey", td.table, name);
681    jt_fks.push(DbForeignKey {
682        constraint_name: src_fk_name,
683        local_column: "source".to_string(),
684        ref_schema: td.module.clone(),
685        ref_table: td.table.clone(),
686    });
687    if let Some((tgt_schema, tgt_table)) = type_map.get(target) {
688        let tgt_fk_name = format!("{}_{}_target_fkey", td.table, name);
689        jt_fks.push(DbForeignKey {
690            constraint_name: tgt_fk_name,
691            local_column: "target".to_string(),
692            ref_schema: tgt_schema.to_string(),
693            ref_table: tgt_table.to_string(),
694        });
695    }
696
697    // An exclusive declared by an interface spans every implementor's
698    // junction, which the shared constraint triggers enforce instead.
699    let mut indexes = Vec::new();
700    if exclusive && !inherited_from_an_interface(schema, td, name) {
701        indexes.push(DbIndex {
702            name: format!("{jt_name}_target_key"),
703            is_unique: true,
704            method: "btree".to_string(),
705            columns: vec!["target".to_string()],
706            predicate: None,
707            key: None,
708        });
709    }
710
711    let triggers: Vec<String> = expected_trigger_names
712        .get(&(td.module.clone(), jt_name.clone()))
713        .cloned()
714        .unwrap_or_default()
715        .into_iter()
716        .collect();
717
718    DbTable {
719        schema: td.module.clone(),
720        name: jt_name,
721        columns: jt_columns,
722        foreign_keys: jt_fks,
723        indexes,
724        checks: vec![],
725        triggers,
726    }
727}
728
729fn ddl_hash(ddl: &str) -> String {
730    use sha2::{Digest, Sha256};
731    let digest = Sha256::digest(ddl.as_bytes());
732    hex::encode(&digest[..8])
733}
734
735/// Serialize a `DbState` to a JSON string for storage in `_pylon."Migrations".db_state`.
736pub fn db_state_to_json(state: &DbState) -> String {
737    serde_json::to_string(state).expect("DbState serialization is infallible")
738}
739
740/// Deserialize a `DbState` from the JSON stored in `_pylon."Migrations".db_state`.
741pub fn db_state_from_json(json: &str) -> Result<DbState, String> {
742    serde_json::from_str(json).map_err(|e| e.to_string())
743}
744
745impl DbState {
746    /// Add a constraint trigger name to an existing table entry.
747    pub fn add_trigger(&mut self, module: &str, table: &str, trigger_name: &str) {
748        if let Some(t) = self.tables.iter_mut().find(|t| t.schema == module && t.name == table) {
749            t.triggers.push(trigger_name.to_string());
750        }
751    }
752}
753
754/// Every trigger name a table (or its junction tables) should have once
755/// `schema` is fully applied — constraint triggers for interface-exclusive
756/// enforcement, deletion-policy triggers, `@pylon.signal` capture triggers,
757/// user-declared schema `Trigger`s, and the unconditional cache-invalidation
758/// trigger.
759///
760/// Shared by the diff engine's own add/drop decisions (`diff_inner`'s
761/// trigger phase) and `schema_to_db_state`'s baseline snapshot — both need
762/// the exact same "what should be there" answer. Having two separate,
763/// independently-maintained copies of this logic is exactly how they
764/// drifted apart before: cache-invalidate and `@pylon.signal` triggers were
765/// only ever recognized by the diff engine's own live-comparison path, so
766/// `schema_to_db_state`'s snapshot never listed them as already
767/// present — making `migration create` propose recreating every single
768/// one of them, forever, even with zero schema changes (confirmed live
769/// against the demo project).
770/// True when a junction type's physical table belongs to a link declared on an
771/// interface.
772///
773/// A junction type's own `table` is the join table of the link that references
774/// it (`"Account.notifications"`). When that link is declared on an interface
775/// the pointer is flattened, so the only join tables that exist are the
776/// implementors' (`"Individual.notifications"`) — nothing is ever created at
777/// the interface's own name, and expecting a trigger there makes every
778/// subsequent diff think one is missing.
779fn junction_table_belongs_to_an_interface(schema: &SchemaDescriptor, td: &TypeDescriptor) -> bool {
780    let Some((owner_table, _)) = td.table.rsplit_once('.') else {
781        return false;
782    };
783    schema
784        .types
785        .iter()
786        .any(|t| t.abstract_ && t.module == td.module && t.table == owner_table)
787}
788
789pub fn expected_triggers(
790    schema: &SchemaDescriptor,
791    type_map: &HashMap<String, (&str, &str)>,
792) -> HashMap<(String, String), HashSet<String>> {
793    let mut expected: HashMap<(String, String), HashSet<String>> = HashMap::new();
794
795    for info in crate::export::interface_exclusive_trigger_infos(schema) {
796        expected
797            .entry((info.impl_module.clone(), info.impl_table.clone()))
798            .or_default()
799            .extend([info.ins_trigger_name, info.upd_trigger_name]);
800    }
801    for info in crate::export::deletion_policy_trigger_infos(schema, type_map) {
802        expected
803            .entry((info.table_module.clone(), info.table_name.clone()))
804            .or_default()
805            .insert(info.trigger_name);
806    }
807    for info in crate::export::signal_trigger_infos(schema) {
808        expected
809            .entry((info.table_module.clone(), info.table_name.clone()))
810            .or_default()
811            .insert(info.trigger_name);
812    }
813    for (module, table, name) in crate::export::user_trigger_names(schema) {
814        expected.entry((module, table)).or_default().insert(name);
815    }
816
817    // Cache-invalidation trigger — every concrete table and every
818    // multi-link junction table (or junction-backed single link's own
819    // physical table), unconditionally (not gated by `[cache].enabled`;
820    // see the cache layer plan's design decision).
821    let mut cache_trigger_tables: HashSet<(String, String)> = HashSet::new();
822    for td in &schema.types {
823        if td.abstract_ || junction_table_belongs_to_an_interface(schema, td) {
824            continue;
825        }
826        cache_trigger_tables.insert((td.module.clone(), td.table.clone()));
827        if !td.junction {
828            for ml in &td.multilinks {
829                cache_trigger_tables.insert((td.module.clone(), format!("{}.{}", td.table, ml.name)));
830            }
831            for l in &td.links {
832                if !l.is_junction_backed() {
833                    continue;
834                }
835                cache_trigger_tables.insert((td.module.clone(), format!("{}.{}", td.table, l.name)));
836            }
837        }
838    }
839    for key in cache_trigger_tables {
840        expected
841            .entry(key)
842            .or_default()
843            .insert("pylon_cache_invalidate".to_string());
844    }
845
846    expected
847}
848
849// ── Rename candidates ─────────────────────────────────────────────────────────
850
851/// A detected potential type (table) rename: a dropped table whose column
852/// structure closely matches a newly-created type.
853#[derive(Debug)]
854pub struct TypeRenameCandidate {
855    pub old_module: String,
856    pub old_table: String,
857    pub new_module: String,
858    pub new_table: String,
859    /// Python-level type name for the new type (used in the prompt).
860    pub new_type_name: String,
861    /// Jaccard similarity of column sets: 0.0–1.0.
862    pub confidence: f64,
863}
864
865/// A detected potential column rename within an existing table: a dropped
866/// column and an added column with the same Postgres type.
867#[derive(Debug)]
868pub struct ColRenameCandidate {
869    pub module: String,
870    pub table: String,
871    pub old_col: String,
872    pub new_col: String,
873    pub pg_type: String,
874}
875
876/// A column that is being made NOT NULL but currently contains (or could
877/// contain) NULL rows, requiring a fill expression to backfill existing rows
878/// before the constraint can be added.
879#[derive(Debug)]
880pub struct FillRequired {
881    /// Schema / Postgres schema name of the table.
882    pub module: String,
883    pub table: String,
884    pub column: String,
885    pub pg_type: String,
886    /// Python-level type name — used in the prompt ("property 'x' of 'Post'").
887    pub type_name: String,
888    /// True when the column doesn't exist yet (ADD COLUMN); false when it
889    /// already exists as nullable (nullability change).
890    pub is_new_column: bool,
891    /// The schema-level `default=` SQL, if one is declared.  When present it
892    /// can be used as an automatic fill expression without prompting the user.
893    pub default_sql: Option<String>,
894}
895
896// ── Diff operation ─────────────────────────────────────────────────────────────
897
898/// A single DDL operation produced by the diff engine.
899#[derive(Debug, Clone)]
900pub struct DiffOp {
901    pub sql: String,
902    /// When true the statement must run outside any transaction wrapper —
903    /// i.e. it uses `CONCURRENTLY`. `pylon migration create` will insert a
904    /// `-- pylon:step non-transactional` marker before these ops.
905    pub non_transactional: bool,
906}
907
908// ── Migration steps ─────────────────────────────────────────────────────────
909//
910// One logical schema-level question ("did you create scalar type 'X'?"),
911// bundling every DDL statement that answers it — so an interactive caller
912// can confirm/reject one object at a time instead of a flat DDL dump.
913
914#[derive(Debug, Clone, Copy, PartialEq, Eq)]
915pub enum Verb {
916    Create,
917    Alter,
918    Drop,
919    Rename,
920}
921
922impl Verb {
923    pub fn as_str(&self) -> &'static str {
924        match self {
925            Verb::Create => "create",
926            Verb::Alter => "alter",
927            Verb::Drop => "drop",
928            Verb::Rename => "rename",
929        }
930    }
931}
932
933/// Stable identity an emitted step is grouped by. Every DDL statement that
934/// answers the same logical question (e.g. every column/FK/trigger change
935/// to one table) shares one key and therefore one step.
936#[derive(Debug, Clone, PartialEq, Eq, Hash)]
937pub enum OpKey {
938    Module(String),
939    /// Covers enums, custom scalar domains, and sequence scalars — all
940    /// three share one object identity (a scalar can only be one of them).
941    Scalar(String, String),
942    /// Covers a concrete type's own table plus everything folded into it:
943    /// column/FK diffs, junction tables for its multi-/through-links,
944    /// vector/search index columns, and constraint/deletion/signal/cache
945    /// triggers — a user thinks of all of these as "altering type X", not
946    /// as separate objects.
947    Table(String, String),
948    Function(String, String),
949    View(String, String),
950    /// A table's foreign keys, deliberately *not* folded into `Table`.
951    ///
952    /// Steps render in insertion order and same-key ops merge into the step
953    /// that already exists, so an FK keyed by its own table would be emitted
954    /// inside that table's `CREATE TABLE` step — before any table created
955    /// later. That made correctness depend on `topo_sort_types` finding a
956    /// perfect order, which it cannot when links form a cycle (a
957    /// self-referencing optional link is enough). Giving foreign keys their
958    /// own key puts every one of them after every table, so the order of the
959    /// table steps stops mattering.
960    ForeignKey(String, String),
961}
962
963#[derive(Debug)]
964pub struct MigrationStep {
965    /// `"did you {verb} {object_desc}?"` — matches the phrasing convention
966    /// this feature is modeled on.
967    pub prompt: String,
968    pub verb: Verb,
969    /// e.g. `"scalar type 'perspective::DomainStatus'"`.
970    pub object_desc: String,
971    /// Every statement that answers this one question, in emission order.
972    /// A statement may contain a `\(placeholder)` token — see
973    /// `required_input` — that the caller must substitute before executing it.
974    pub ddl: Vec<DiffOp>,
975    pub op_key: OpKey,
976    /// Free-form expressions the caller must supply before this step's `ddl`
977    /// is usable — e.g. a conversion expression for a property's type
978    /// change. Empty for the common case.
979    pub required_input: Vec<RequiredInput>,
980}
981
982impl MigrationStep {
983    /// This step's DDL with every `\(placeholder)` token substituted —
984    /// `overrides.get(placeholder)` if present, else that input's own
985    /// `default_expr`. Statements naming no placeholder pass through as-is.
986    pub fn resolved_ddl(&self, overrides: &HashMap<String, String>) -> Vec<DiffOp> {
987        self.ddl
988            .iter()
989            .map(|op| {
990                let mut sql = op.sql.clone();
991                for input in &self.required_input {
992                    let value = overrides.get(&input.placeholder).unwrap_or(&input.default_expr);
993                    sql = sql.replace(&format!("\\({})", input.placeholder), value);
994                }
995                DiffOp {
996                    sql,
997                    non_transactional: op.non_transactional,
998                }
999            })
1000            .collect()
1001    }
1002}
1003
1004/// One `\(placeholder)` token embedded in a step's DDL that the caller must
1005/// resolve to a PyQL expression (compiled via `query::compile_fill_expr`,
1006/// evaluated against `type_name`) before executing that statement — mirrors
1007/// how a fill expression is resolved, just for a type-change's conversion
1008/// expression instead of a backfill.
1009#[derive(Debug, Clone)]
1010pub struct RequiredInput {
1011    /// The `\(name)` token to substitute in the owning step's DDL text.
1012    pub placeholder: String,
1013    /// Prompt text for the caller to show the user.
1014    pub prompt: String,
1015    /// A reasonable default expression — the caller may offer this and
1016    /// accept it on an empty response, matching how a fill's declared
1017    /// default is offered.
1018    pub default_expr: String,
1019    /// Qualified type name to compile the user's PyQL expression against.
1020    pub type_name: String,
1021}
1022
1023fn verbosename_module(name: &str) -> String {
1024    format!("module '{name}'")
1025}
1026
1027/// Is this a CHECK constraint Pylon named — `{table}[_{column}]_{hash}_check`?
1028/// The 8-hex-digit segment is what distinguishes one from a hand-written
1029/// constraint that happens to end in `_check`.
1030fn is_generated_check_name(name: &str) -> bool {
1031    let Some(rest) = name.strip_suffix("_check") else {
1032        return false;
1033    };
1034    match rest.rsplit_once('_') {
1035        Some((prefix, hash)) => !prefix.is_empty() && hash.len() == 8 && hash.chars().all(|c| c.is_ascii_hexdigit()),
1036        None => false,
1037    }
1038}
1039
1040/// Covers both enums and custom scalar domains — Pylon describes both as
1041/// "scalar type" in migration prompts, matching how neither reads naturally
1042/// as its own separate noun to someone reviewing a schema change.
1043fn verbosename_scalar(module: &str, name: &str) -> String {
1044    format!("scalar type '{module}::{name}'")
1045}
1046
1047fn verbosename_type(module: &str, name: &str) -> String {
1048    format!("object type '{module}::{name}'")
1049}
1050
1051fn verbosename_interface(module: &str, name: &str) -> String {
1052    format!("interface type '{module}::{name}'")
1053}
1054
1055fn verbosename_function(module: &str, name: &str) -> String {
1056    format!("function '{module}::{name}'")
1057}
1058
1059/// Accumulates `DiffOp`s into `MigrationStep`s keyed by `OpKey`, preserving
1060/// first-insertion order. A key's verb/description are fixed by whichever
1061/// call inserts it first; later calls under the same key just append DDL
1062/// (and any required-input entries).
1063#[derive(Default)]
1064struct StepBuilder {
1065    order: Vec<OpKey>,
1066    drafts: HashMap<OpKey, (Verb, String, Vec<DiffOp>, Vec<RequiredInput>)>,
1067}
1068
1069impl StepBuilder {
1070    fn new() -> Self {
1071        Self::default()
1072    }
1073
1074    fn push(&mut self, key: OpKey, verb: Verb, object_desc: impl Into<String>, op: DiffOp) {
1075        self.extend(key, verb, object_desc, vec![op]);
1076    }
1077
1078    fn extend(&mut self, key: OpKey, verb: Verb, object_desc: impl Into<String>, ops: Vec<DiffOp>) {
1079        self.extend_with_input(key, verb, object_desc, ops, vec![]);
1080    }
1081
1082    fn extend_with_input(
1083        &mut self,
1084        key: OpKey,
1085        verb: Verb,
1086        object_desc: impl Into<String>,
1087        ops: Vec<DiffOp>,
1088        inputs: Vec<RequiredInput>,
1089    ) {
1090        if ops.is_empty() && inputs.is_empty() {
1091            return;
1092        }
1093        use std::collections::hash_map::Entry;
1094        match self.drafts.entry(key.clone()) {
1095            Entry::Occupied(mut e) => {
1096                e.get_mut().2.extend(ops);
1097                e.get_mut().3.extend(inputs);
1098            }
1099            Entry::Vacant(e) => {
1100                e.insert((verb, object_desc.into(), ops, inputs));
1101                self.order.push(key);
1102            }
1103        }
1104    }
1105
1106    /// Where a key's step currently sits in render order, if it has one yet.
1107    fn position(&self, key: &OpKey) -> Option<usize> {
1108        self.order.iter().position(|existing| existing == key)
1109    }
1110
1111    fn finish(self) -> Vec<MigrationStep> {
1112        let Self { order, mut drafts } = self;
1113        order
1114            .into_iter()
1115            .map(|key| {
1116                let (verb, object_desc, ddl, required_input) = drafts.remove(&key).unwrap();
1117                let prompt = format!("did you {} {}?", verb.as_str(), object_desc);
1118                MigrationStep {
1119                    prompt,
1120                    verb,
1121                    object_desc,
1122                    ddl,
1123                    op_key: key,
1124                    required_input,
1125                }
1126            })
1127            .collect()
1128    }
1129}
1130
1131/// Guidance for re-diffing after a rejected rename candidate — mirrors the
1132/// two ambiguity detectors below; a plain create/alter/drop has no
1133/// alternative reading to search for, so rejecting one of those just
1134/// excludes it (handled by the interactive caller, not here).
1135#[derive(Debug, Default, Clone)]
1136pub struct Guidance {
1137    /// `(old_module, old_table, new_module, new_table)` tuples the caller
1138    /// has rejected as a rename — never propose these again.
1139    pub banned_type_renames: HashSet<(String, String, String, String)>,
1140    /// `(module, table, old_col, new_col)` tuples the caller has rejected
1141    /// as a rename.
1142    pub banned_col_renames: HashSet<(String, String, String, String)>,
1143}
1144
1145// ── Public API ────────────────────────────────────────────────────────────────
1146
1147/// Compute ordered DDL SQL strings to bring a database in sync with `target`.
1148/// All statements use plain (non-CONCURRENTLY) index creation — suitable for
1149/// `watch` mode where everything runs inside a single transaction.
1150pub fn diff_schema(target: &SchemaDescriptor, current: &DbState) -> Result<Vec<String>, String> {
1151    Ok(flatten_ops(diff_inner(target, current, false, &HashMap::new())?)
1152        .into_iter()
1153        .map(|op| op.sql)
1154        .collect())
1155}
1156
1157/// Compute ordered `DiffOp`s suitable for a migration file body.
1158/// Index creation on pre-existing tables uses `CONCURRENTLY` and is marked
1159/// `non_transactional = true` so `create` can insert step-boundary markers.
1160pub fn diff_schema_ops(target: &SchemaDescriptor, current: &DbState) -> Result<Vec<DiffOp>, String> {
1161    Ok(flatten_ops(diff_inner(target, current, true, &HashMap::new())?))
1162}
1163
1164/// True when `target` differs from `previous` in ANY way at all — not just
1165/// the DDL-visible parts `diff_schema_steps`/`diff_schema_ops` can see.
1166///
1167/// Some schema semantics have zero physical Postgres footprint: a
1168/// property/link's `is_readonly` flag, mutation `rewrites`, computed
1169/// pointers, session/computed globals, and (once added) pub/sub `Channel`
1170/// declarations are all enforced purely by `pylon-core`'s own compiler
1171/// consulting `SchemaDescriptor` — there is no column, constraint, or
1172/// catalog object for live-database introspection (`DbState`) to ever see,
1173/// no matter how they change. Before this function existed, a schema edit
1174/// confined to one of these was **structurally invisible** to `migration
1175/// create`/`watch`: `diff_schema_steps`/`diff_schema_ops` would report zero
1176/// DDL, the CLI would print "No schema changes detected" and exit, and
1177/// `_pylon."Schema"` (what every connecting client actually compiles
1178/// against — see `pylon.client._install_migrated_schema`) would never be
1179/// updated. That's not "changing it doesn't require a migration" (the
1180/// intended, documented behavior for e.g. `readonly`) — it's "changing it
1181/// can *never* be migrated at all," permanently, until some unrelated
1182/// DDL-visible change happens to piggyback one through.
1183///
1184/// Deliberately does **not** enumerate which fields are DDL-invisible —
1185/// that list has already grown twice (`readonly`/rewrites, then `Channel`)
1186/// and would silently miss the next one. Instead this compares the two
1187/// schemas' full JSON content (the same `serde_json` round-trip already
1188/// used for `_pylon."Schema"` storage — see `SchemaDescriptor::to_json`/
1189/// `from_json` in `pylon-py`), so *any* field on *any* descriptor —
1190/// present now or added later — is covered automatically. Callers combine
1191/// this with `diff_schema_steps`/`diff_schema_ops`'s own result: if there
1192/// are DDL steps, this check is redundant (a migration is already
1193/// happening); it only changes behavior in the previously-broken case,
1194/// zero DDL steps but real content drift.
1195///
1196/// `previous = None` (no migration has ever been applied to this database
1197/// yet) compares against an empty `SchemaDescriptor` — matches `target`
1198/// only when `target` itself is completely empty.
1199pub fn schema_content_changed(target: &SchemaDescriptor, previous: Option<&SchemaDescriptor>) -> bool {
1200    let previous = previous.cloned().unwrap_or_default();
1201    // `.ok()` + compare-as-Option rather than `.expect()`: a serialization
1202    // failure here would be a `serde` bug, not a real difference in schema
1203    // content — but crashing `migration create` over it would be worse
1204    // than just treating that (should-never-happen) case as "unchanged".
1205    serde_json::to_value(target).ok() != serde_json::to_value(&previous).ok()
1206}
1207
1208/// Compute the diff as one `MigrationStep` per logical schema-level question
1209/// — for an interactive caller that confirms/rejects one object at a time
1210/// instead of a flat DDL dump. `fill_index` marks columns whose NOT NULL
1211/// constraint is deferred to a caller-supplied backfill (see
1212/// `diff_schema_ops_with_renames_and_fills`'s doc comment).
1213pub fn diff_schema_steps(
1214    target: &SchemaDescriptor,
1215    current: &DbState,
1216    fill_index: &HashMap<(String, String), HashSet<String>>,
1217) -> Result<Vec<MigrationStep>, String> {
1218    diff_inner(target, current, true, fill_index)
1219}
1220
1221/// Flattens steps to a plain `DiffOp` list for every non-interactive caller
1222/// (`watch`, `diff_schema_ops`, squash) — resolving any `\(placeholder)`
1223/// token to its `RequiredInput::default_expr` along the way, since these
1224/// callers have no interactive loop to ask the user for an override. The
1225/// interactive path (`diff_schema_steps`) returns `MigrationStep`s
1226/// untouched instead, placeholders and all, for the caller to resolve itself.
1227fn flatten_ops(steps: Vec<MigrationStep>) -> Vec<DiffOp> {
1228    let no_overrides = HashMap::new();
1229    steps.iter().flat_map(|s| s.resolved_ddl(&no_overrides)).collect()
1230}
1231
1232/// Diff two live-database snapshots (used by squash to capture the net effect
1233/// of a range of migrations applied to an ephemeral shadow database).
1234/// "before" = state at the start of the squashed range, "after" = state at
1235/// the end. Returns DiffOps suitable for a migration file body.
1236pub fn diff_states(before: &DbState, after: &DbState) -> Vec<DiffOp> {
1237    diff_states_inner(before, after)
1238}
1239
1240/// Detect potential type (table) renames: tables that exist in `current` but
1241/// not in `target`, paired with types that exist in `target` but not in
1242/// `current`, where the column-set Jaccard similarity meets a threshold.
1243/// Candidates already rejected once (`guidance.banned_type_renames`) are
1244/// never proposed again — a re-diff after "no" should offer something else.
1245pub fn detect_type_renames(
1246    target: &SchemaDescriptor,
1247    current: &DbState,
1248    guidance: &Guidance,
1249) -> Vec<TypeRenameCandidate> {
1250    let target_keys: HashSet<(&str, &str)> = target
1251        .types
1252        .iter()
1253        .filter(|t| !t.abstract_ && !t.junction)
1254        .map(|t| (t.module.as_str(), t.table.as_str()))
1255        .collect();
1256    let current_keys: HashSet<(&str, &str)> = current
1257        .tables
1258        .iter()
1259        .map(|t| (t.schema.as_str(), t.name.as_str()))
1260        .collect();
1261
1262    let dropped: Vec<&DbTable> = current
1263        .tables
1264        .iter()
1265        .filter(|t| !target_keys.contains(&(t.schema.as_str(), t.name.as_str())))
1266        .collect();
1267    let created: Vec<&TypeDescriptor> = target
1268        .types
1269        .iter()
1270        .filter(|t| !t.abstract_ && !t.junction)
1271        .filter(|t| !current_keys.contains(&(t.module.as_str(), t.table.as_str())))
1272        .collect();
1273
1274    if dropped.is_empty() || created.is_empty() {
1275        return vec![];
1276    }
1277
1278    let mut candidates: Vec<TypeRenameCandidate> = Vec::new();
1279    for dropped_t in &dropped {
1280        let old_cols: HashSet<&str> = dropped_t
1281            .columns
1282            .iter()
1283            .map(|c| c.name.as_str())
1284            .filter(|n| !n.starts_with("__"))
1285            .collect();
1286        for new_type in &created {
1287            let new_cols: HashSet<&str> = new_type.properties.iter().map(|p| p.name.as_str()).collect();
1288            let intersection = old_cols.intersection(&new_cols).count();
1289            let union_size = old_cols.union(&new_cols).count();
1290            if union_size == 0 {
1291                continue;
1292            }
1293            let confidence = intersection as f64 / union_size as f64;
1294            let banned = guidance.banned_type_renames.contains(&(
1295                dropped_t.schema.clone(),
1296                dropped_t.name.clone(),
1297                new_type.module.clone(),
1298                new_type.table.clone(),
1299            ));
1300            if confidence >= 0.4 && !banned {
1301                candidates.push(TypeRenameCandidate {
1302                    old_module: dropped_t.schema.clone(),
1303                    old_table: dropped_t.name.clone(),
1304                    new_module: new_type.module.clone(),
1305                    new_table: new_type.table.clone(),
1306                    new_type_name: new_type.name.clone(),
1307                    confidence,
1308                });
1309            }
1310        }
1311    }
1312    candidates.sort_by(|a, b| {
1313        b.confidence
1314            .partial_cmp(&a.confidence)
1315            .unwrap_or(std::cmp::Ordering::Equal)
1316    });
1317    candidates
1318}
1319
1320/// Detect potential column renames within tables that exist in both `current`
1321/// and `target`. A candidate is a (dropped_col, added_col) pair in the same
1322/// table with the same Postgres type. Candidates already rejected once
1323/// (`guidance.banned_col_renames`) are never proposed again.
1324pub fn detect_col_renames(
1325    target: &SchemaDescriptor,
1326    current: &DbState,
1327    guidance: &Guidance,
1328) -> Vec<ColRenameCandidate> {
1329    let cur_tables: HashMap<(&str, &str), &DbTable> = current
1330        .tables
1331        .iter()
1332        .map(|t| ((t.schema.as_str(), t.name.as_str()), t))
1333        .collect();
1334
1335    let mut candidates: Vec<ColRenameCandidate> = Vec::new();
1336    for td in &target.types {
1337        if td.abstract_ || td.junction {
1338            continue;
1339        }
1340        let Some(cur) = cur_tables.get(&(td.module.as_str(), td.table.as_str())) else {
1341            continue;
1342        };
1343
1344        // Target columns as owned Vec to avoid temporary String lifetime issues.
1345        let target_cols: Vec<(String, String)> = td
1346            .properties
1347            .iter()
1348            .map(|p| (p.name.clone(), col_type_str(p).to_string()))
1349            .chain(
1350                td.links
1351                    .iter()
1352                    .filter(|l| !l.is_junction_backed())
1353                    .map(|l| (format!("{}_id", l.name), "uuid".to_string())),
1354            )
1355            .collect();
1356
1357        // Current columns (skip internal __*__ columns).
1358        let cur_cols: Vec<(&str, &str)> = cur
1359            .columns
1360            .iter()
1361            .filter(|c| !c.name.starts_with("__"))
1362            .map(|c| (c.name.as_str(), c.pg_type.as_str()))
1363            .collect();
1364
1365        // Dropped: in current but not in target.
1366        let dropped: Vec<(&str, &str)> = cur_cols
1367            .iter()
1368            .copied()
1369            .filter(|(name, _)| !target_cols.iter().any(|(t, _)| t.as_str() == *name))
1370            .collect();
1371        // Added: in target but not in current.
1372        let added: Vec<(&str, &str)> = target_cols
1373            .iter()
1374            .filter(|(name, _)| !cur_cols.iter().any(|&(c, _)| c == name.as_str()))
1375            .map(|(n, t)| (n.as_str(), t.as_str()))
1376            .collect();
1377
1378        if dropped.is_empty() || added.is_empty() {
1379            continue;
1380        }
1381
1382        // Match dropped↔added pairs by Postgres type.
1383        // Only propose when unambiguous: exactly one dropped and one added per type.
1384        let mut dropped_by_type: HashMap<&str, Vec<&str>> = HashMap::new();
1385        for (name, pg_type) in &dropped {
1386            dropped_by_type.entry(pg_type).or_default().push(name);
1387        }
1388        let mut added_by_type: HashMap<&str, Vec<&str>> = HashMap::new();
1389        for (name, pg_type) in &added {
1390            added_by_type.entry(pg_type).or_default().push(name);
1391        }
1392
1393        for (pg_type, dropped_names) in &dropped_by_type {
1394            if let Some(added_names) = added_by_type.get(pg_type)
1395                && dropped_names.len() == 1
1396                && added_names.len() == 1
1397            {
1398                let banned = guidance.banned_col_renames.contains(&(
1399                    td.module.clone(),
1400                    td.table.clone(),
1401                    dropped_names[0].to_string(),
1402                    added_names[0].to_string(),
1403                ));
1404                if !banned {
1405                    candidates.push(ColRenameCandidate {
1406                        module: td.module.clone(),
1407                        table: td.table.clone(),
1408                        old_col: dropped_names[0].to_string(),
1409                        new_col: added_names[0].to_string(),
1410                        pg_type: pg_type.to_string(),
1411                    });
1412                }
1413            }
1414        }
1415    }
1416    candidates
1417}
1418
1419/// Detect all properties that are being made NOT NULL but whose existing rows
1420/// may contain NULL values and therefore require a fill expression.
1421///
1422/// Returns a `FillRequired` for:
1423/// - New NOT NULL columns without a schema-level `default=` on existing tables.
1424/// - Existing nullable columns that the target marks as NOT NULL (with or without
1425///   a default — the default is surfaced as `default_sql` for auto-fill).
1426///
1427/// New tables are excluded (no rows yet).  PK columns are excluded (always NOT
1428/// NULL by definition).
1429pub fn detect_fill_required(target: &SchemaDescriptor, current: &DbState) -> Vec<FillRequired> {
1430    let cur_tables: HashMap<(&str, &str), &DbTable> = current
1431        .tables
1432        .iter()
1433        .map(|t| ((t.schema.as_str(), t.name.as_str()), t))
1434        .collect();
1435
1436    let mut result: Vec<FillRequired> = Vec::new();
1437
1438    for td in &target.types {
1439        if td.abstract_ || td.junction {
1440            continue;
1441        }
1442        let Some(cur) = cur_tables.get(&(td.module.as_str(), td.table.as_str())) else {
1443            continue;
1444        };
1445
1446        let cur_col_map: HashMap<&str, &DbColumn> = cur.columns.iter().map(|c| (c.name.as_str(), c)).collect();
1447
1448        for p in &td.properties {
1449            if p.nullable || p.is_pk {
1450                continue;
1451            }
1452            match cur_col_map.get(p.name.as_str()) {
1453                None => {
1454                    // New required column — only needs a fill when there's no default.
1455                    if p.default_sql.is_none() {
1456                        result.push(FillRequired {
1457                            module: td.module.clone(),
1458                            table: td.table.clone(),
1459                            column: p.name.clone(),
1460                            pg_type: col_type_str(p).to_string(),
1461                            type_name: td.name.clone(),
1462                            is_new_column: true,
1463                            default_sql: None,
1464                        });
1465                    }
1466                }
1467                Some(cur_col) if cur_col.nullable => {
1468                    // Existing nullable column becoming NOT NULL.
1469                    result.push(FillRequired {
1470                        module: td.module.clone(),
1471                        table: td.table.clone(),
1472                        column: p.name.clone(),
1473                        pg_type: col_type_str(p).to_string(),
1474                        type_name: td.name.clone(),
1475                        is_new_column: false,
1476                        default_sql: p.default_sql.clone(),
1477                    });
1478                }
1479                _ => {}
1480            }
1481        }
1482
1483        for l in &td.links {
1484            if l.nullable || l.is_junction_backed() {
1485                continue;
1486            }
1487            let col = format!("{}_id", l.name);
1488            match cur_col_map.get(col.as_str()) {
1489                None => {
1490                    result.push(FillRequired {
1491                        module: td.module.clone(),
1492                        table: td.table.clone(),
1493                        column: col,
1494                        pg_type: "uuid".to_string(),
1495                        type_name: td.name.clone(),
1496                        is_new_column: true,
1497                        default_sql: None,
1498                    });
1499                }
1500                Some(cur_col) if cur_col.nullable => {
1501                    result.push(FillRequired {
1502                        module: td.module.clone(),
1503                        table: td.table.clone(),
1504                        column: col,
1505                        pg_type: "uuid".to_string(),
1506                        type_name: td.name.clone(),
1507                        is_new_column: false,
1508                        default_sql: None,
1509                    });
1510                }
1511                _ => {}
1512            }
1513        }
1514    }
1515    result
1516}
1517
1518/// Rewrite `state` as though the confirmed renames had already happened, so
1519/// the diff that follows sees the new names as present and reports only the
1520/// differences that aren't the rename.
1521///
1522/// The rename DDL itself is *not* emitted here, because its two callers
1523/// disagree about who owns it: the flat-`DiffOp` form emits it alongside
1524/// this, while the `MigrationStep` form leaves it to whoever resolved the
1525/// renames in the first place. What they can't disagree about is this edit
1526/// — it has to describe exactly what the rename DDL does, or the diff
1527/// re-derives as a DROP + CREATE the very rename it was told about.
1528fn apply_renames(
1529    state: &mut DbState,
1530    type_renames: &[(String, String, String, String)],
1531    col_renames: &[(String, String, String, String)],
1532) {
1533    for (old_mod, old_table, new_mod, new_table) in type_renames {
1534        if let Some(t) = state
1535            .tables
1536            .iter_mut()
1537            .find(|t| &t.schema == old_mod && &t.name == old_table)
1538        {
1539            t.schema = new_mod.clone();
1540            t.name = new_table.clone();
1541        }
1542    }
1543    for (module, table, old_col, new_col) in col_renames {
1544        if let Some(t) = state
1545            .tables
1546            .iter_mut()
1547            .find(|t| &t.schema == module && &t.name == table)
1548            && let Some(col) = t.columns.iter_mut().find(|c| &c.name == old_col)
1549        {
1550            col.name = new_col.clone();
1551        }
1552    }
1553}
1554
1555/// Full diff with confirmed renames and fill expressions applied.
1556///
1557/// `type_renames`: `(old_module, old_table, new_module, new_table)`.
1558/// `col_renames`:  `(module, table, old_col, new_col)`.
1559/// `fills`:        `(module, table, column, sql_expr)` — one entry per column
1560///                 that needs a backfill before its NOT NULL constraint is set.
1561///
1562/// For each fill the function emits:
1563///   1. `UPDATE … SET col = expr WHERE col IS NULL;`
1564///   2. `ALTER TABLE … ALTER COLUMN col SET NOT NULL;`
1565///
1566/// For newly-added NOT NULL columns that are in the fills list the ADD COLUMN
1567/// is emitted as nullable so the fill can succeed before the constraint is set.
1568pub fn diff_schema_ops_with_renames_and_fills(
1569    target: &SchemaDescriptor,
1570    current: &DbState,
1571    type_renames: &[(String, String, String, String)],
1572    col_renames: &[(String, String, String, String)],
1573    fills: &[(String, String, String, String)],
1574) -> Result<Vec<DiffOp>, String> {
1575    let mut ops: Vec<DiffOp> = Vec::new();
1576    let mut modified = current.clone();
1577
1578    // ── Emit the rename DDL ───────────────────────────────────────────────────
1579    for (old_mod, old_table, new_mod, new_table) in type_renames {
1580        if old_mod == new_mod {
1581            push_tx(
1582                &mut ops,
1583                format!("ALTER TABLE {} RENAME TO {};", qn(old_mod, old_table), qi(new_table)),
1584            );
1585        } else {
1586            push_tx(
1587                &mut ops,
1588                format!("ALTER TABLE {} SET SCHEMA {};", qn(old_mod, old_table), qi(new_mod)),
1589            );
1590            push_tx(
1591                &mut ops,
1592                format!("ALTER TABLE {} RENAME TO {};", qn(new_mod, old_table), qi(new_table)),
1593            );
1594        }
1595    }
1596    for (module, table, old_col, new_col) in col_renames {
1597        push_tx(
1598            &mut ops,
1599            format!(
1600                "ALTER TABLE {} RENAME COLUMN {} TO {};",
1601                qn(module, table),
1602                qi(old_col),
1603                qi(new_col)
1604            ),
1605        );
1606    }
1607
1608    // ── ...and make the baseline match what that DDL will have done ───────────
1609    apply_renames(&mut modified, type_renames, col_renames);
1610
1611    // ── Build fill index so emit_column_diff can defer NOT NULL for fills ─────
1612    let mut fill_index: HashMap<(String, String), HashSet<String>> = HashMap::new();
1613    for (module, table, col, _) in fills {
1614        fill_index
1615            .entry((module.clone(), table.clone()))
1616            .or_default()
1617            .insert(col.clone());
1618    }
1619
1620    // ── Standard diff (renames already resolved in `modified`) ───────────────
1621    let mut diff_ops = flatten_ops(diff_inner(target, &modified, true, &fill_index)?);
1622    ops.append(&mut diff_ops);
1623
1624    // ── Fill DDL: UPDATE backfill + SET NOT NULL ──────────────────────────────
1625    for (module, table, col, fill_expr) in fills {
1626        push_tx(
1627            &mut ops,
1628            format!(
1629                "UPDATE {} SET {} = {} WHERE {} IS NULL;",
1630                qn(module, table),
1631                qi(col),
1632                fill_expr,
1633                qi(col)
1634            ),
1635        );
1636        push_tx(
1637            &mut ops,
1638            format!(
1639                "ALTER TABLE {} ALTER COLUMN {} SET NOT NULL;",
1640                qn(module, table),
1641                qi(col)
1642            ),
1643        );
1644    }
1645
1646    Ok(ops)
1647}
1648
1649/// Like `diff_schema_ops_with_renames_and_fills` but returns `MigrationStep`s
1650/// grouped by object instead of a flat `DiffOp` list — for the interactive
1651/// confirmation loop. Renames themselves aren't represented as steps here;
1652/// the caller resolves those first (see `detect_type_renames`/
1653/// `detect_col_renames` + `Guidance`) and passes the confirmed set in, same
1654/// as the flat-`DiffOp` version. A fill's `UPDATE` + `SET NOT NULL` folds
1655/// into its own table's step (falling back to a standalone step in the rare
1656/// case that table has no other change in this diff).
1657pub fn diff_schema_steps_with_renames_and_fills(
1658    target: &SchemaDescriptor,
1659    current: &DbState,
1660    type_renames: &[(String, String, String, String)],
1661    col_renames: &[(String, String, String, String)],
1662    fills: &[(String, String, String, String)],
1663) -> Result<Vec<MigrationStep>, String> {
1664    let mut modified = current.clone();
1665    apply_renames(&mut modified, type_renames, col_renames);
1666
1667    let mut fill_index: HashMap<(String, String), HashSet<String>> = HashMap::new();
1668    for (module, table, col, _) in fills {
1669        fill_index
1670            .entry((module.clone(), table.clone()))
1671            .or_default()
1672            .insert(col.clone());
1673    }
1674
1675    let mut steps = diff_inner(target, &modified, true, &fill_index)?;
1676
1677    for (module, table, col, fill_expr) in fills {
1678        let fill_ops = vec![
1679            DiffOp {
1680                sql: format!(
1681                    "UPDATE {} SET {} = {} WHERE {} IS NULL;",
1682                    qn(module, table),
1683                    qi(col),
1684                    fill_expr,
1685                    qi(col)
1686                ),
1687                non_transactional: false,
1688            },
1689            DiffOp {
1690                sql: format!(
1691                    "ALTER TABLE {} ALTER COLUMN {} SET NOT NULL;",
1692                    qn(module, table),
1693                    qi(col)
1694                ),
1695                non_transactional: false,
1696            },
1697        ];
1698        match steps
1699            .iter_mut()
1700            .find(|s| matches!(&s.op_key, OpKey::Table(m, t) if m == module && t == table))
1701        {
1702            Some(step) => step.ddl.extend(fill_ops),
1703            None => steps.push(MigrationStep {
1704                prompt: format!("did you {} {}?", Verb::Alter.as_str(), verbosename_type(module, table)),
1705                verb: Verb::Alter,
1706                object_desc: verbosename_type(module, table),
1707                ddl: fill_ops,
1708                required_input: vec![],
1709                op_key: OpKey::Table(module.clone(), table.clone()),
1710            }),
1711        }
1712    }
1713
1714    Ok(steps)
1715}
1716
1717// ── Identifier helpers ────────────────────────────────────────────────────────
1718
1719fn qi(s: &str) -> String {
1720    format!("\"{}\"", s.replace('"', "\"\""))
1721}
1722
1723fn pg_schema(module: &str) -> String {
1724    if module == "default" {
1725        "\"public\"".into()
1726    } else {
1727        qi(module)
1728    }
1729}
1730
1731/// Whether `pg_type` names one of the schema's own enums (or an array of one).
1732/// PostgreSQL will not cast between two enum types directly, so such a column
1733/// has to be converted through `text`.
1734fn enum_target(schema: &SchemaDescriptor, pg_type: &str) -> bool {
1735    let bare = pg_type.strip_suffix("[]").unwrap_or(pg_type);
1736    schema
1737        .enums
1738        .iter()
1739        .any(|e| bare == qn(&e.module, &e.name) || bare == qi(&e.name))
1740}
1741
1742fn qn(schema: &str, name: &str) -> String {
1743    format!("{}.{}", pg_schema(schema), qi(name))
1744}
1745
1746// ── Topological sort (referenced types before referencing) ────────────────────
1747
1748/// Referenced types before referencing ones, best-effort.
1749///
1750/// **A cycle here is not an error.** `emit_create_table` emits plain `uuid`
1751/// link columns with no `REFERENCES` clause — every FK is added afterwards, in
1752/// Phase 6, once all the tables exist — so no link actually constrains
1753/// creation order, and a cycle of them constrains nothing either. This used to
1754/// reject any back-edge, which failed on perfectly ordinary shapes: a
1755/// self-referencing optional link (`WorkflowAction.parent -> WorkflowAction`,
1756/// `QuestionnaireChapter.parent -> QuestionnaireChapter`) is a tree, not an
1757/// impossibility. The cycle that *is* impossible — one made of *required*
1758/// links, which no INSERT could ever satisfy — is rejected by the schema
1759/// walker at `pylon.finalize()` time, with a message naming the cycle and how
1760/// to break it. Back-edges are simply skipped, leaving the ordering a hint.
1761fn topo_sort_types(types: &[TypeDescriptor], polymorphic: &HashSet<String>) -> Vec<usize> {
1762    let idx_of: HashMap<String, usize> = types
1763        .iter()
1764        .enumerate()
1765        .map(|(i, t)| (format!("{}::{}", t.module, t.name), i))
1766        .collect();
1767
1768    // Three-colour DFS: 0=unvisited, 1=in current path (gray), 2=done (black).
1769    let mut colour = vec![0u8; types.len()];
1770    let mut order: Vec<usize> = Vec::with_capacity(types.len());
1771
1772    fn visit(
1773        i: usize,
1774        types: &[TypeDescriptor],
1775        idx_of: &HashMap<String, usize>,
1776        polymorphic: &HashSet<String>,
1777        colour: &mut Vec<u8>,
1778        order: &mut Vec<usize>,
1779    ) {
1780        if colour[i] != 0 {
1781            return; // already done, or a back-edge we are deliberately ignoring
1782        }
1783        colour[i] = 1;
1784        // Multilinks — and junction-backed single links, stored the same way —
1785        // keep their columns on the junction table, and a link to an interface
1786        // gets no FK at all, so none of those order anything either.
1787        for l in &types[i].links {
1788            if l.is_junction_backed() || polymorphic.contains(&l.target) {
1789                continue;
1790            }
1791            if let Some(&dep) = idx_of.get(&l.target) {
1792                visit(dep, types, idx_of, polymorphic, colour, order);
1793            }
1794        }
1795        colour[i] = 2;
1796        order.push(i);
1797    }
1798
1799    for i in 0..types.len() {
1800        visit(i, types, &idx_of, polymorphic, &mut colour, &mut order);
1801    }
1802    order
1803}
1804
1805/// A property's actual DDL column type: its own registered-scalar DOMAIN
1806/// name when it has one (`column_type`), else its plain `pg_type` (with the
1807/// `__nt__:` nominal-tuple marker resolved to `jsonb`).
1808fn col_type_str(p: &crate::schema::PropertyDescriptor) -> &str {
1809    p.column_type.as_deref().unwrap_or_else(|| resolved_pg_type(&p.pg_type))
1810}
1811
1812/// Maps a Pylon-internal base `pg_type` spelling to PostgreSQL's own
1813/// canonical `format_type()` display name (`int8` -> `bigint`, etc.), so a
1814/// freshly-introspected `DbColumn.pg_type` can be compared against a
1815/// target schema's type without every alias spelling looking like drift.
1816/// Recurses into array element types (`int8[]` -> `bigint[]`).
1817fn canonical_pg_type(pg_type: &str) -> String {
1818    if let Some(elem) = pg_type.strip_suffix("[]") {
1819        return format!("{}[]", canonical_pg_type(elem));
1820    }
1821    match pg_type {
1822        "int2" => "smallint",
1823        "int4" => "integer",
1824        "int8" => "bigint",
1825        "float4" => "real",
1826        "float8" => "double precision",
1827        "timestamptz" => "timestamp with time zone",
1828        "timestamp" => "timestamp without time zone",
1829        "time" => "time without time zone",
1830        other => other,
1831    }
1832    .to_string()
1833}
1834
1835/// The bare (unqualified, unquoted) identifier at the end of a possibly
1836/// schema-qualified, possibly-quoted PostgreSQL type reference — e.g.
1837/// `"public"."Gender"`, `public."Gender"`, and `"Gender"` all yield
1838/// `Gender`. `format_type()` omits the schema qualifier whenever it's on
1839/// `search_path` (which a live column's introspected type always is, but
1840/// Pylon's own qualified references never bother checking), so comparing
1841/// only this trailing segment is what lets a domain/enum-typed column's
1842/// *type itself* changing be distinguished from a same-domain column that
1843/// merely looks unqualified.
1844fn bare_type_name(pg_type: &str) -> &str {
1845    pg_type.rsplit('.').next().unwrap_or(pg_type).trim_matches('"')
1846}
1847
1848/// Whether a property's own DDL column type (`col_type_str`'s output —
1849/// either a registered scalar's schema-qualified DOMAIN reference or a
1850/// plain base type) actually differs from a live column's introspected
1851/// `format_type()` string, warranting `ALTER COLUMN ... TYPE`.
1852fn pg_type_changed(target: &str, current: &str) -> bool {
1853    if target.starts_with('"') {
1854        bare_type_name(target) != bare_type_name(current)
1855    } else {
1856        canonical_pg_type(target) != canonical_pg_type(current)
1857    }
1858}
1859
1860// ── Core diff implementation ──────────────────────────────────────────────────
1861
1862fn diff_inner(
1863    target: &SchemaDescriptor,
1864    current: &DbState,
1865    for_migration: bool,
1866    fill_index: &HashMap<(String, String), HashSet<String>>,
1867) -> Result<Vec<MigrationStep>, String> {
1868    let mut steps = StepBuilder::new();
1869
1870    let cur_schemas: HashSet<&str> = current.schemas.iter().map(|s| s.as_str()).collect();
1871    let cur_tables: HashMap<(&str, &str), &DbTable> = current
1872        .tables
1873        .iter()
1874        .map(|t| ((t.schema.as_str(), t.name.as_str()), t))
1875        .collect();
1876    let cur_enums: HashMap<(&str, &str), &DbEnum> = current
1877        .enums
1878        .iter()
1879        .map(|e| ((e.schema.as_str(), e.name.as_str()), e))
1880        .collect();
1881    let cur_domains: HashSet<(&str, &str)> = current
1882        .domains
1883        .iter()
1884        .map(|d| (d.schema.as_str(), d.name.as_str()))
1885        .collect();
1886    let cur_sequences: HashSet<(&str, &str)> = current
1887        .sequences
1888        .iter()
1889        .map(|s| (s.schema.as_str(), s.name.as_str()))
1890        .collect();
1891    let cur_views: HashMap<(&str, &str), &str> = current
1892        .views
1893        .iter()
1894        .map(|v| ((v.schema.as_str(), v.name.as_str()), v.body_hash.as_str()))
1895        .collect();
1896    let cur_functions: HashMap<(&str, &str), &str> = current
1897        .functions
1898        .iter()
1899        .map(|f| ((f.schema.as_str(), f.name.as_str()), f.body_hash.as_str()))
1900        .collect();
1901
1902    let type_map: HashMap<String, (&str, &str)> = target
1903        .types
1904        .iter()
1905        .map(|t| {
1906            (
1907                format!("{}::{}", t.module, t.name),
1908                (t.module.as_str(), t.table.as_str()),
1909            )
1910        })
1911        .collect();
1912    let polymorphic = crate::export::polymorphic_types(target);
1913
1914    // Every kind of top-level declaration can live in a module of its own,
1915    // including one with no types/scalars/enums at all (see the matching
1916    // comment on `export::emit_schemas`, which had the same gap — a
1917    // function/global/alias-only module's own CREATE SCHEMA step was never
1918    // generated, so its first CREATE FUNCTION/etc. failed outright).
1919    let mut target_schemas: HashSet<String> = HashSet::new();
1920    for t in &target.types {
1921        target_schemas.insert(t.module.clone());
1922    }
1923    for e in &target.enums {
1924        target_schemas.insert(e.module.clone());
1925    }
1926    for s in &target.scalars {
1927        target_schemas.insert(s.module.clone());
1928    }
1929    for f in &target.functions {
1930        target_schemas.insert(f.module.clone());
1931    }
1932    for g in &target.globals {
1933        target_schemas.insert(g.module.clone());
1934    }
1935    for a in &target.aliases {
1936        target_schemas.insert(a.module.clone());
1937    }
1938
1939    // ── Phase 1: modules ─────────────────────────────────────────────────────
1940    for module in &target_schemas {
1941        if module == "default" {
1942            continue;
1943        } // public always exists
1944        if !cur_schemas.contains(module.as_str()) {
1945            steps.push(
1946                OpKey::Module(module.clone()),
1947                Verb::Create,
1948                verbosename_module(module),
1949                DiffOp {
1950                    sql: format!("CREATE SCHEMA IF NOT EXISTS {};", pg_schema(module)),
1951                    non_transactional: false,
1952                },
1953            );
1954        }
1955    }
1956
1957    // ── Phase 2: enums (described as "scalar type", matching how a custom
1958    // scalar domain is described — see `verbosename_scalar`) ─────────────────
1959    for e in &target.enums {
1960        match cur_enums.get(&(e.module.as_str(), e.name.as_str())) {
1961            None => {
1962                let members: Vec<String> = e
1963                    .members
1964                    .iter()
1965                    .map(|m| format!("'{}'", m.replace('\'', "''")))
1966                    .collect();
1967                steps.push(
1968                    OpKey::Scalar(e.module.clone(), e.name.clone()),
1969                    Verb::Create,
1970                    verbosename_scalar(&e.module, &e.name),
1971                    DiffOp {
1972                        sql: format!(
1973                            "DO $$ BEGIN CREATE TYPE {}.{} AS ENUM ({}); \
1974                         EXCEPTION WHEN duplicate_object THEN NULL; END $$;",
1975                            pg_schema(&e.module),
1976                            qi(&e.name),
1977                            members.join(", ")
1978                        ),
1979                        non_transactional: false,
1980                    },
1981                );
1982            }
1983            Some(existing) => {
1984                let existing_set: HashSet<&str> = existing.members.iter().map(|m| m.as_str()).collect();
1985                for member in &e.members {
1986                    if !existing_set.contains(member.as_str()) {
1987                        steps.push(
1988                            OpKey::Scalar(e.module.clone(), e.name.clone()),
1989                            Verb::Alter,
1990                            verbosename_scalar(&e.module, &e.name),
1991                            DiffOp {
1992                                sql: format!(
1993                                    "ALTER TYPE {}.{} ADD VALUE IF NOT EXISTS '{}';",
1994                                    pg_schema(&e.module),
1995                                    qi(&e.name),
1996                                    member.replace('\'', "''")
1997                                ),
1998                                non_transactional: false,
1999                            },
2000                        );
2001                    }
2002                }
2003            }
2004        }
2005    }
2006
2007    // ── Phase 2.5: sequences (for sequence scalars — folded into the same
2008    // scalar's step, see `OpKey::Scalar`'s doc comment) ──────────────────────
2009    for s in &target.scalars {
2010        if s.is_sequence {
2011            let seq_name = format!("{}_seq", s.name);
2012            if !cur_sequences.contains(&(s.module.as_str(), seq_name.as_str())) {
2013                let verb = if cur_domains.contains(&(s.module.as_str(), s.name.as_str())) {
2014                    Verb::Alter
2015                } else {
2016                    Verb::Create
2017                };
2018                steps.push(
2019                    OpKey::Scalar(s.module.clone(), s.name.clone()),
2020                    verb,
2021                    verbosename_scalar(&s.module, &s.name),
2022                    DiffOp {
2023                        sql: format!(
2024                            "CREATE SEQUENCE IF NOT EXISTS {}.{};",
2025                            pg_schema(&s.module),
2026                            qi(&seq_name)
2027                        ),
2028                        non_transactional: false,
2029                    },
2030                );
2031            }
2032        }
2033    }
2034
2035    // ── Phase 3: custom scalar domains ───────────────────────────────────────
2036    for s in &target.scalars {
2037        if !cur_domains.contains(&(s.module.as_str(), s.name.as_str())) {
2038            let check_clause = crate::export::scalar_check_clauses(target, &s.module, &s.name);
2039            steps.push(
2040                OpKey::Scalar(s.module.clone(), s.name.clone()),
2041                Verb::Create,
2042                verbosename_scalar(&s.module, &s.name),
2043                DiffOp {
2044                    sql: format!(
2045                        "DO $do$ BEGIN CREATE DOMAIN {}.{} AS {}{}; \
2046                     EXCEPTION WHEN duplicate_object THEN NULL; END $do$;",
2047                        pg_schema(&s.module),
2048                        qi(&s.name),
2049                        s.pg_type,
2050                        check_clause
2051                    ),
2052                    non_transactional: false,
2053                },
2054            );
2055        }
2056    }
2057
2058    // ── Phase 3.2: a existing domain's own CHECK constraints ─────────────────
2059    // `CREATE DOMAIN` runs once and never again, so without this an edited
2060    // `MaxLen`/`Regexp` on a custom scalar reached a fresh database and no
2061    // other. Pylon owns the domain outright, so any constraint on it that the
2062    // schema no longer names is one of its own leftovers.
2063    let expected_domain_checks = crate::export::scalar_check_constraints(target);
2064    for cur_domain in &current.domains {
2065        if !target
2066            .scalars
2067            .iter()
2068            .any(|s| s.module == cur_domain.schema && s.name == cur_domain.name)
2069        {
2070            continue; // the domain itself is going, or is not Pylon's
2071        }
2072        let wanted: Vec<&(String, String, String, String)> = expected_domain_checks
2073            .iter()
2074            .filter(|(module, name, _, _)| *module == cur_domain.schema && *name == cur_domain.name)
2075            .collect();
2076        let domain = format!("{}.{}", pg_schema(&cur_domain.schema), qi(&cur_domain.name));
2077        let unchanged = wanted.len() == cur_domain.checks.len()
2078            && wanted
2079                .iter()
2080                .all(|(_, _, cname, _)| cur_domain.checks.iter().any(|name| name == cname));
2081        if unchanged {
2082            continue;
2083        }
2084        let mut local: Vec<DiffOp> = Vec::new();
2085        // Clear the whole set rather than diffing it by name: Postgres names a
2086        // domain's inline constraints itself (`EmailAddress_check1`), so a
2087        // schema-derived baseline can never say what is actually there. Pylon
2088        // owns the domain, so reconciling all of it is safe and reproducible.
2089        push_tx(
2090            &mut local,
2091            format!(
2092                "DO $do$ DECLARE existing record; BEGIN                  FOR existing IN SELECT conname FROM pg_constraint                  WHERE contypid = '{domain}'::regtype AND contype = 'c' LOOP                  EXECUTE format('ALTER DOMAIN {domain} DROP CONSTRAINT %I', existing.conname);                  END LOOP; END $do$;"
2093            ),
2094        );
2095        for (_, _, cname, expr) in wanted {
2096            push_tx(
2097                &mut local,
2098                format!("ALTER DOMAIN {} ADD CONSTRAINT {} CHECK ({});", domain, qi(cname), expr),
2099            );
2100        }
2101        if !local.is_empty() {
2102            steps.extend(
2103                OpKey::Scalar(cur_domain.schema.clone(), cur_domain.name.clone()),
2104                Verb::Alter,
2105                verbosename_scalar(&cur_domain.schema, &cur_domain.name),
2106                local,
2107            );
2108        }
2109    }
2110
2111    // ── Phase 3.5: scalar functions (before tables — table DEFAULTs may call them) ──
2112    let scalar_fn_ddls = crate::export::scalar_function_ddl_with_names(target).map_err(|e| e.to_string())?;
2113    for (module, name, ddl) in scalar_fn_ddls {
2114        let emit = if for_migration {
2115            let hash = ddl_hash(&ddl);
2116            cur_functions
2117                .get(&(module.as_str(), name.as_str()))
2118                .map(|&h| h != hash)
2119                .unwrap_or(true)
2120        } else {
2121            true
2122        };
2123        if emit {
2124            let verb = if cur_functions.contains_key(&(module.as_str(), name.as_str())) {
2125                Verb::Alter
2126            } else {
2127                Verb::Create
2128            };
2129            steps.push(
2130                OpKey::Function(module.clone(), name.clone()),
2131                verb,
2132                verbosename_function(&module, &name),
2133                DiffOp {
2134                    sql: ddl,
2135                    non_transactional: false,
2136                },
2137            );
2138        }
2139    }
2140
2141    // ── Phase 4 & 5: tables (create new or alter existing) ───────────────────
2142    let sort_order = topo_sort_types(&target.types, &polymorphic);
2143
2144    // Track which tables are created in this diff (needed for CONCURRENTLY decision).
2145    let mut new_tables: HashSet<(String, String)> = HashSet::new();
2146
2147    for &i in &sort_order {
2148        let td = &target.types[i];
2149        if td.abstract_ || td.junction {
2150            continue;
2151        }
2152        let key = (td.module.as_str(), td.table.as_str());
2153        match cur_tables.get(&key) {
2154            None => {
2155                let mut local: Vec<DiffOp> = Vec::new();
2156                emit_create_table(td, target, &mut local);
2157                steps.extend(
2158                    OpKey::Table(td.module.clone(), td.table.clone()),
2159                    Verb::Create,
2160                    verbosename_type(&td.module, &td.name),
2161                    local,
2162                );
2163                new_tables.insert((td.module.clone(), td.table.clone()));
2164            }
2165            Some(existing) => {
2166                let fill_cols = fill_index
2167                    .get(&(td.module.clone(), td.table.clone()))
2168                    .cloned()
2169                    .unwrap_or_default();
2170                let mut local: Vec<DiffOp> = Vec::new();
2171                let mut inputs: Vec<RequiredInput> = Vec::new();
2172                emit_column_diff(td, existing, &mut local, for_migration, &fill_cols, target, &mut inputs);
2173                steps.extend_with_input(
2174                    OpKey::Table(td.module.clone(), td.table.clone()),
2175                    Verb::Alter,
2176                    verbosename_type(&td.module, &td.name),
2177                    local,
2178                    inputs,
2179                );
2180            }
2181        }
2182    }
2183
2184    // ── Phase 6: FK constraints for new and existing tables (folded into the
2185    // same table step) ────────────────────────────────────────────────────────
2186    // Must run for brand-new tables too (`emit_create_table` only emits plain
2187    // `uuid` link columns, never a `REFERENCES` clause) — a table absent from
2188    // `cur_tables` still needs every one of its FKs added, just against an
2189    // empty "already has" set instead of an introspected one.
2190    for &i in &sort_order {
2191        let td = &target.types[i];
2192        if td.abstract_ || td.junction {
2193            continue;
2194        }
2195        let existing = cur_tables.get(&(td.module.as_str(), td.table.as_str())).copied();
2196        let verb = if new_tables.contains(&(td.module.clone(), td.table.clone())) {
2197            Verb::Create
2198        } else {
2199            Verb::Alter
2200        };
2201        let mut local: Vec<DiffOp> = Vec::new();
2202        emit_fk_diff(td, existing, &type_map, &polymorphic, &mut local);
2203        steps.extend(
2204            OpKey::ForeignKey(td.module.clone(), td.table.clone()),
2205            verb,
2206            verbosename_type(&td.module, &td.name),
2207            local,
2208        );
2209    }
2210
2211    // ── Phase 6b: junction target FKs ─────────────────────────────────────────
2212    // Keyed by the junction table rather than its owner, so these land in a
2213    // step of their own after every `CREATE TABLE` — the target of a junction
2214    // is an unrelated type whose table the owner's own step cannot depend on.
2215    for (jt_module, jt_name, cname, ddl) in crate::export::junction_fk_constraints(target, &type_map) {
2216        let already_there = cur_tables
2217            .get(&(jt_module.as_str(), jt_name.as_str()))
2218            .map(|t| t.foreign_keys.iter().any(|fk| fk.constraint_name == cname))
2219            .unwrap_or(false);
2220        if already_there {
2221            continue;
2222        }
2223        let verb = if cur_tables.contains_key(&(jt_module.as_str(), jt_name.as_str())) {
2224            Verb::Alter
2225        } else {
2226            Verb::Create
2227        };
2228        let mut local: Vec<DiffOp> = Vec::new();
2229        push_tx(&mut local, ddl);
2230        steps.extend(
2231            OpKey::ForeignKey(jt_module.clone(), jt_name.clone()),
2232            verb,
2233            format!("link table '{}.{}'", jt_module, jt_name),
2234            local,
2235        );
2236    }
2237
2238    // ── Phase 6c: CHECK constraints ───────────────────────────────────────────
2239    // This phase did not exist. `export_schema` emitted every CHECK the schema
2240    // implies and the migration path emitted none, so `pylon migration apply`
2241    // produced databases with no `MaxLen`/`MinValue`/`Regexp`/`OneOf`/
2242    // `Expression` enforcement at all — a 200-character value went straight
2243    // into a `MaxLen(24)` column.
2244    for (module, table, cname, ddl) in crate::export::check_constraints(target).map_err(|e| e.to_string())? {
2245        let already_there = cur_tables
2246            .get(&(module.as_str(), table.as_str()))
2247            .map(|t| t.checks.iter().any(|c| c.constraint_name == cname))
2248            .unwrap_or(false);
2249        if already_there {
2250            continue;
2251        }
2252        let verb = if cur_tables.contains_key(&(module.as_str(), table.as_str())) {
2253            Verb::Alter
2254        } else {
2255            Verb::Create
2256        };
2257        let mut local: Vec<DiffOp> = Vec::new();
2258        push_tx(&mut local, ddl);
2259        steps.extend(
2260            OpKey::ForeignKey(module.clone(), format!("{table}#checks")),
2261            verb,
2262            verbosename_type(&module, &table),
2263            local,
2264        );
2265    }
2266
2267    // ── Phase 7: junction tables for new multi-links (and junction-backed
2268    // single links, which share the exact same junction-table machinery,
2269    // just capped to one row per source) — folded into the owning type's
2270    // step, since a user thinks of this as "altering/creating the type to
2271    // add a multi-link", not as a separate table ────────────────────────────
2272    for &i in &sort_order {
2273        let td = &target.types[i];
2274        if td.abstract_ || td.junction {
2275            continue;
2276        }
2277        let owner_key = OpKey::Table(td.module.clone(), td.table.clone());
2278        let owner_verb = if new_tables.contains(&(td.module.clone(), td.table.clone())) {
2279            Verb::Create
2280        } else {
2281            Verb::Alter
2282        };
2283        let owner_desc = verbosename_type(&td.module, &td.name);
2284        for ml in &td.multilinks {
2285            let jt = format!("{}.{}", td.table, ml.name);
2286            if !cur_tables.contains_key(&(td.module.as_str(), jt.as_str())) {
2287                let mut local: Vec<DiffOp> = Vec::new();
2288                emit_junction_table(td, &ml.name, ml.through.as_deref(), target, false, &mut local);
2289                steps.extend(owner_key.clone(), owner_verb, owner_desc.clone(), local);
2290                new_tables.insert((td.module.clone(), jt));
2291            }
2292        }
2293        for l in &td.links {
2294            if !l.is_junction_backed() {
2295                continue;
2296            }
2297            let jt = format!("{}.{}", td.table, l.name);
2298            if !cur_tables.contains_key(&(td.module.as_str(), jt.as_str())) {
2299                let mut local: Vec<DiffOp> = Vec::new();
2300                emit_junction_table(td, &l.name, l.through.as_deref(), target, true, &mut local);
2301                steps.extend(owner_key.clone(), owner_verb, owner_desc.clone(), local);
2302                new_tables.insert((td.module.clone(), jt));
2303            }
2304        }
2305    }
2306
2307    // ── Phase 8: vector columns + indexes (folded into the owning type's step) ──
2308    for &i in &sort_order {
2309        let td = &target.types[i];
2310        if td.abstract_ || td.vector_indexes.is_empty() {
2311            continue;
2312        }
2313        let existing = cur_tables.get(&(td.module.as_str(), td.table.as_str()));
2314        let table_is_new = new_tables.contains(&(td.module.clone(), td.table.clone()));
2315        let owner_key = OpKey::Table(td.module.clone(), td.table.clone());
2316        let owner_verb = if table_is_new { Verb::Create } else { Verb::Alter };
2317        let owner_desc = verbosename_type(&td.module, &td.name);
2318
2319        for vi in &td.vector_indexes {
2320            let col = vi.column_name();
2321            if existing
2322                .map(|t| t.columns.iter().any(|c| c.name == col))
2323                .unwrap_or(false)
2324            {
2325                continue;
2326            }
2327            let mut local: Vec<DiffOp> = Vec::new();
2328            push_tx(
2329                &mut local,
2330                format!(
2331                    "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} vector({});",
2332                    qn(&td.module, &td.table),
2333                    qi(&col),
2334                    vi.dimensions
2335                ),
2336            );
2337            let idx_name = match &vi.index_name {
2338                None => format!("{}__vector__", td.table),
2339                Some(n) => format!("{}__vector_{}__", td.table, n),
2340            };
2341            // Pre-existing tables: CONCURRENTLY (non-transactional step in migration).
2342            // New tables: plain CREATE INDEX (no rows, no locking concern).
2343            let use_concurrently = for_migration && !table_is_new;
2344            let idx_sql = if use_concurrently {
2345                format!(
2346                    "CREATE INDEX CONCURRENTLY IF NOT EXISTS {} ON {} USING hnsw ({} {});",
2347                    qi(&idx_name),
2348                    qn(&td.module, &td.table),
2349                    qi(&col),
2350                    vi.ops_class()
2351                )
2352            } else {
2353                format!(
2354                    "CREATE INDEX IF NOT EXISTS {} ON {} USING hnsw ({} {});",
2355                    qi(&idx_name),
2356                    qn(&td.module, &td.table),
2357                    qi(&col),
2358                    vi.ops_class()
2359                )
2360            };
2361            local.push(DiffOp {
2362                sql: idx_sql,
2363                non_transactional: use_concurrently,
2364            });
2365            steps.extend(owner_key.clone(), owner_verb, owner_desc.clone(), local);
2366        }
2367    }
2368
2369    // ── Phase 9: search tsvector columns + GIN indexes (folded likewise) ─────
2370    for &i in &sort_order {
2371        let td = &target.types[i];
2372        if td.abstract_ || td.search_indexes.is_empty() {
2373            continue;
2374        }
2375        let existing = cur_tables.get(&(td.module.as_str(), td.table.as_str()));
2376        let table_is_new = new_tables.contains(&(td.module.clone(), td.table.clone()));
2377        let owner_key = OpKey::Table(td.module.clone(), td.table.clone());
2378        let owner_verb = if table_is_new { Verb::Create } else { Verb::Alter };
2379        let owner_desc = verbosename_type(&td.module, &td.name);
2380
2381        for si in &td.search_indexes {
2382            if si.backend != SearchBackend::Postgres {
2383                continue;
2384            }
2385            let col = si.column_name();
2386            if existing
2387                .map(|t| t.columns.iter().any(|c| c.name == col))
2388                .unwrap_or(false)
2389            {
2390                continue;
2391            }
2392            let mut local: Vec<DiffOp> = Vec::new();
2393            let parts: Vec<String> = si
2394                .pointers
2395                .iter()
2396                .map(|sf| {
2397                    format!(
2398                        "setweight(to_tsvector('english', coalesce({}, '')), '{}')",
2399                        qi(&sf.name),
2400                        sf.weight.as_str()
2401                    )
2402                })
2403                .collect();
2404            let expr = if parts.len() == 1 {
2405                parts.into_iter().next().unwrap()
2406            } else {
2407                parts.join(" || ")
2408            };
2409            push_tx(
2410                &mut local,
2411                format!(
2412                    "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} tsvector GENERATED ALWAYS AS ({}) STORED;",
2413                    qn(&td.module, &td.table),
2414                    qi(&col),
2415                    expr
2416                ),
2417            );
2418            let idx_name = match &si.index_name {
2419                None => format!("{}__search__", td.table),
2420                Some(n) => format!("{}__search_{}__", td.table, n),
2421            };
2422            let use_concurrently = for_migration && !table_is_new;
2423            let idx_sql = if use_concurrently {
2424                format!(
2425                    "CREATE INDEX CONCURRENTLY IF NOT EXISTS {} ON {} USING gin ({});",
2426                    qi(&idx_name),
2427                    qn(&td.module, &td.table),
2428                    qi(&col)
2429                )
2430            } else {
2431                format!(
2432                    "CREATE INDEX IF NOT EXISTS {} ON {} USING gin ({});",
2433                    qi(&idx_name),
2434                    qn(&td.module, &td.table),
2435                    qi(&col)
2436                )
2437            };
2438            local.push(DiffOp {
2439                sql: idx_sql,
2440                non_transactional: use_concurrently,
2441            });
2442            steps.extend(owner_key.clone(), owner_verb, owner_desc.clone(), local);
2443        }
2444    }
2445
2446    // ── Unique and plain indexes ─────────────────────────────────────────────
2447    // Only the vector and search passes above used to run here, so every
2448    // `Exclusive` and `Index` the schema declared existed on a fresh install
2449    // (`export_schema` emits them) and nowhere else: 283 exclusives in the
2450    // jaldis schema, none of them enforced in a migrated database.
2451    let expected_state = schema_to_db_state(target);
2452    let current_indexes: HashMap<(&str, &str), &[DbIndex]> = current
2453        .tables
2454        .iter()
2455        .map(|t| ((t.schema.as_str(), t.name.as_str()), t.indexes.as_slice()))
2456        .collect();
2457    for table in &expected_state.tables {
2458        let existing = current_indexes
2459            .get(&(table.schema.as_str(), table.name.as_str()))
2460            .copied()
2461            .unwrap_or_default();
2462        let table_is_new = !current_indexes.contains_key(&(table.schema.as_str(), table.name.as_str()));
2463        for idx in &table.indexes {
2464            // Vector and search indexes have neither; their own passes above
2465            // know the operator class and expression each needs.
2466            if idx.columns.is_empty() && idx.key.is_none() {
2467                continue;
2468            }
2469            // Postgres auto-names an index created without a name, so the
2470            // database's name is no guide for a plain column list -- what
2471            // settles that is the key itself: same columns, same uniqueness,
2472            // and partial or not. An index over an expression has no column
2473            // list to compare and is matched by the name this generates.
2474            let already_there = match &idx.key {
2475                Some(_) => existing.iter().any(|b| b.name == idx.name),
2476                None => existing.iter().any(|b| {
2477                    b.columns == idx.columns
2478                        && b.is_unique == idx.is_unique
2479                        && b.predicate.is_some() == idx.predicate.is_some()
2480                }),
2481            };
2482            if already_there {
2483                continue;
2484            }
2485            let use_concurrently = for_migration && !table_is_new;
2486            let concurrently = if use_concurrently { "CONCURRENTLY " } else { "" };
2487            let unique = if idx.is_unique { "UNIQUE " } else { "" };
2488            let body = match &idx.key {
2489                Some(key) => key.clone(),
2490                None => format!("({})", idx.columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ")),
2491            };
2492            let sql = format!(
2493                "CREATE {unique}INDEX {concurrently}IF NOT EXISTS {} ON {} {}{};",
2494                qi(&capped_index_name(idx.name.clone())),
2495                qn(&table.schema, &table.name),
2496                body,
2497                idx.predicate.clone().unwrap_or_default(),
2498            );
2499            let owner_key = OpKey::Table(table.schema.clone(), table.name.clone());
2500            let owner_verb = if table_is_new { Verb::Create } else { Verb::Alter };
2501            let owner_desc = verbosename_type(&table.schema, &table.name);
2502            steps.extend(
2503                owner_key,
2504                owner_verb,
2505                owner_desc,
2506                vec![DiffOp {
2507                    sql,
2508                    non_transactional: use_concurrently,
2509                }],
2510            );
2511        }
2512    }
2513
2514    // ── Phase 10: interface views (after all tables exist) ───────────────────
2515    // In watch mode always re-emit (CREATE OR REPLACE VIEW is idempotent).
2516    // In migration mode only emit new or changed views.
2517    for (module, name, ddl) in crate::export::interface_view_ddl_with_names(target) {
2518        let emit = if for_migration {
2519            let hash = ddl_hash(&ddl);
2520            cur_views
2521                .get(&(module.as_str(), name.as_str()))
2522                .map(|&h| h != hash)
2523                .unwrap_or(true)
2524        } else {
2525            true
2526        };
2527        if emit {
2528            let verb = if cur_views.contains_key(&(module.as_str(), name.as_str())) {
2529                Verb::Alter
2530            } else {
2531                Verb::Create
2532            };
2533            steps.push(
2534                OpKey::View(module.clone(), name.clone()),
2535                verb,
2536                verbosename_interface(&module, &name),
2537                DiffOp {
2538                    sql: ddl,
2539                    non_transactional: false,
2540                },
2541            );
2542        }
2543    }
2544
2545    // ── Phase 10.5: junction-backed exclusive link cross-implementor views ───
2546    // Same add/hash-diff treatment as Phase 10's interface object views —
2547    // see `export::interface_junction_view_ddl_with_names`'s own doc comment for
2548    // why a junction-backed exclusive link needs its own helper view at
2549    // all (its value lives in each implementor's own separate junction
2550    // table, never on the owner row). Must run before Phase 11.5, which
2551    // queries these views from the trigger functions it emits.
2552    for (module, name, ddl) in crate::export::interface_junction_view_ddl_with_names(target) {
2553        let emit = if for_migration {
2554            let hash = ddl_hash(&ddl);
2555            cur_views
2556                .get(&(module.as_str(), name.as_str()))
2557                .map(|&h| h != hash)
2558                .unwrap_or(true)
2559        } else {
2560            true
2561        };
2562        if emit {
2563            let verb = if cur_views.contains_key(&(module.as_str(), name.as_str())) {
2564                Verb::Alter
2565            } else {
2566                Verb::Create
2567            };
2568            steps.push(
2569                OpKey::View(module.clone(), name.clone()),
2570                verb,
2571                verbosename_interface(&module, &name),
2572                DiffOp {
2573                    sql: ddl,
2574                    non_transactional: false,
2575                },
2576            );
2577        }
2578    }
2579
2580    // ── Phase 10.6: drop views the schema no longer declares ─────────────────
2581    // An interface demoted to a mixin, or one whose junction-backed exclusive
2582    // link is gone, leaves a view nothing recreates — the same reasoning
2583    // Phase 12 applies to a table the schema stopped declaring.
2584    let target_views: HashSet<(String, String)> = crate::export::interface_view_ddl_with_names(target)
2585        .into_iter()
2586        .chain(crate::export::interface_junction_view_ddl_with_names(target))
2587        .map(|(module, name, _)| (module, name))
2588        .collect();
2589    for view in &current.views {
2590        if !target_views.contains(&(view.schema.clone(), view.name.clone())) {
2591            steps.push(
2592                OpKey::View(view.schema.clone(), view.name.clone()),
2593                Verb::Drop,
2594                verbosename_interface(&view.schema, &view.name),
2595                DiffOp {
2596                    sql: format!("DROP VIEW IF EXISTS {};", qn(&view.schema, &view.name)),
2597                    non_transactional: false,
2598                },
2599            );
2600        }
2601    }
2602
2603    // ── Phase 11: object-returning functions (after tables and views exist) ──
2604    let obj_fn_ddls = crate::export::object_function_ddl_with_names(target).map_err(|e| e.to_string())?;
2605    for (module, name, ddl) in obj_fn_ddls {
2606        let emit = if for_migration {
2607            let hash = ddl_hash(&ddl);
2608            cur_functions
2609                .get(&(module.as_str(), name.as_str()))
2610                .map(|&h| h != hash)
2611                .unwrap_or(true)
2612        } else {
2613            true
2614        };
2615        if emit {
2616            let verb = if cur_functions.contains_key(&(module.as_str(), name.as_str())) {
2617                Verb::Alter
2618            } else {
2619                Verb::Create
2620            };
2621            steps.push(
2622                OpKey::Function(module.clone(), name.clone()),
2623                verb,
2624                verbosename_function(&module, &name),
2625                DiffOp {
2626                    sql: ddl,
2627                    non_transactional: false,
2628                },
2629            );
2630        }
2631    }
2632
2633    // Every physical table (including multilink/junction-backed-link tables)
2634    // `target` still wants — computed here, ahead of Phase 11.5, so that
2635    // phase can tell a table being *fully dropped* (Phase 12) apart from one
2636    // that's merely losing a trigger. Reused as-is by Phase 12 itself below.
2637    let mut target_tables: HashSet<(String, String)> = HashSet::new();
2638    for td in &target.types {
2639        if !td.abstract_ {
2640            target_tables.insert((td.module.clone(), td.table.clone()));
2641        }
2642        if !td.abstract_ && !td.junction {
2643            for ml in &td.multilinks {
2644                target_tables.insert((td.module.clone(), format!("{}.{}", td.table, ml.name)));
2645            }
2646            for l in &td.links {
2647                if !l.is_junction_backed() {
2648                    continue;
2649                }
2650                target_tables.insert((td.module.clone(), format!("{}.{}", td.table, l.name)));
2651            }
2652        }
2653    }
2654
2655    // ── Phase 11.5: interface exclusive constraint triggers (folded into the
2656    // owning concrete table's step — a user thinks of these as part of
2657    // "altering type X", not as separate objects) ────────────────────────────
2658    {
2659        let infos = crate::export::interface_exclusive_trigger_infos(target);
2660        let cur_trigger_map: HashMap<(&str, &str), HashSet<&str>> = current
2661            .tables
2662            .iter()
2663            .map(|t| {
2664                (
2665                    (t.schema.as_str(), t.name.as_str()),
2666                    t.triggers.iter().map(|n| n.as_str()).collect::<HashSet<_>>(),
2667                )
2668            })
2669            .collect();
2670
2671        // What every table's trigger set *should* be once `target` is fully
2672        // applied — the single source of truth both the add-decisions below
2673        // and the final drop phase compare `current` against (see
2674        // `expected_triggers`'s own doc comment for why this must be one
2675        // shared computation, not two).
2676        let expected_trigger_map = expected_triggers(target, &type_map);
2677
2678        // A physical table's (module, name) -> the type whose step its
2679        // trigger changes should fold into. Concrete tables own themselves;
2680        // junction tables (a multi-link or junction-backed single link's own
2681        // physical table) fold into the type that declared that link.
2682        let owner_of = |module: &str, table: &str| -> (OpKey, Verb, String) {
2683            for &i in &sort_order {
2684                let td = &target.types[i];
2685                if td.abstract_ || td.junction {
2686                    continue;
2687                }
2688                let is_owner = td.module == module
2689                    && (td.table == table
2690                        || td
2691                            .multilinks
2692                            .iter()
2693                            .any(|ml| format!("{}.{}", td.table, ml.name) == table)
2694                        || td
2695                            .links
2696                            .iter()
2697                            .any(|l| l.is_junction_backed() && format!("{}.{}", td.table, l.name) == table));
2698                if is_owner {
2699                    let verb = if new_tables.contains(&(td.module.clone(), td.table.clone())) {
2700                        Verb::Create
2701                    } else {
2702                        Verb::Alter
2703                    };
2704                    return (
2705                        OpKey::Table(td.module.clone(), td.table.clone()),
2706                        verb,
2707                        verbosename_type(&td.module, &td.name),
2708                    );
2709                }
2710            }
2711            (
2712                OpKey::Table(module.to_string(), table.to_string()),
2713                Verb::Alter,
2714                verbosename_type(module, table),
2715            )
2716        };
2717
2718        struct PendingExclTrigger<'a> {
2719            info: &'a crate::export::ExclTriggerInfo,
2720            need_ins: bool,
2721            need_upd: bool,
2722            owner: (OpKey, Verb, String),
2723        }
2724
2725        let pending: Vec<PendingExclTrigger<'_>> = infos
2726            .iter()
2727            .filter_map(|info| {
2728                let cur = cur_trigger_map
2729                    .get(&(info.impl_module.as_str(), info.impl_table.as_str()))
2730                    .cloned()
2731                    .unwrap_or_default();
2732                let need_ins = !cur.contains(info.ins_trigger_name.as_str());
2733                let need_upd = !cur.contains(info.upd_trigger_name.as_str());
2734                (need_ins || need_upd).then(|| PendingExclTrigger {
2735                    info,
2736                    need_ins,
2737                    need_upd,
2738                    owner: owner_of(&info.impl_module, &info.impl_table),
2739                })
2740            })
2741            .collect();
2742
2743        // One guard function is shared by every implementor of the interface,
2744        // so it is emitted with a single implementor's step — and that has to
2745        // be whichever of them renders *first*, or the others' triggers name a
2746        // function that does not exist yet. Steps render in the order their
2747        // keys were inserted, so an implementor whose table was created
2748        // earlier in the topological order already sits ahead here; one whose
2749        // step does not exist yet can only be inserted by this phase, in
2750        // `pending` order.
2751        let mut fn_carrier: HashMap<String, usize> = HashMap::new();
2752        for (index, entry) in pending.iter().enumerate() {
2753            let position = steps.position(&entry.owner.0).unwrap_or(usize::MAX);
2754            let beats_carrier = match fn_carrier.get(&entry.info.fn_name) {
2755                Some(&carrier) => position < steps.position(&pending[carrier].owner.0).unwrap_or(usize::MAX),
2756                None => true,
2757            };
2758            if beats_carrier {
2759                fn_carrier.insert(entry.info.fn_name.clone(), index);
2760            }
2761        }
2762
2763        for (index, entry) in pending.into_iter().enumerate() {
2764            let mut local: Vec<DiffOp> = Vec::new();
2765            if fn_carrier.get(&entry.info.fn_name) == Some(&index) {
2766                push_tx(&mut local, entry.info.fn_ddl.clone());
2767            }
2768            if entry.need_ins {
2769                push_tx(&mut local, entry.info.ins_ddl.clone());
2770            }
2771            if entry.need_upd {
2772                push_tx(&mut local, entry.info.upd_ddl.clone());
2773            }
2774            let (key, verb, desc) = entry.owner;
2775            steps.extend(key, verb, desc, local);
2776        }
2777
2778        // Deletion-policy triggers (single-link Source-side DeleteTarget/
2779        // DeleteTargetIfOrphan, multilink Source-side same, multilink
2780        // Target-side DeleteSource) — previously only `export_schema`'s
2781        // fresh-install path emitted these; the incremental migration path
2782        // silently never did, so these `on_delete` policies never took
2783        // effect for anything added via a real migration (confirmed live,
2784        // `tests/live_execution_on_delete.rs`, before this fix).
2785        for info in crate::export::deletion_policy_trigger_infos(target, &type_map) {
2786            let cur = cur_trigger_map
2787                .get(&(info.table_module.as_str(), info.table_name.as_str()))
2788                .cloned()
2789                .unwrap_or_default();
2790            if !cur.contains(info.trigger_name.as_str()) {
2791                let (key, verb, desc) = owner_of(&info.table_module, &info.table_name);
2792                steps.extend(
2793                    key,
2794                    verb,
2795                    desc,
2796                    vec![DiffOp {
2797                        sql: info.ddl.clone(),
2798                        non_transactional: false,
2799                    }],
2800                );
2801            }
2802        }
2803
2804        // `@pylon.signal` capture triggers — only for types with a
2805        // non-empty `signals` list (see `export::signal_trigger_infos`).
2806        // Adding/removing a signal handler changes the combined `on=`
2807        // bitmask the walker attaches to the schema, so this naturally
2808        // adds/drops the trigger on the next migration, same as any other
2809        // schema change — no separate sync step needed.
2810        for info in crate::export::signal_trigger_infos(target) {
2811            let cur = cur_trigger_map
2812                .get(&(info.table_module.as_str(), info.table_name.as_str()))
2813                .cloned()
2814                .unwrap_or_default();
2815            if !cur.contains(info.trigger_name.as_str()) {
2816                let (key, verb, desc) = owner_of(&info.table_module, &info.table_name);
2817                steps.extend(
2818                    key,
2819                    verb,
2820                    desc,
2821                    vec![DiffOp {
2822                        sql: info.ddl.clone(),
2823                        non_transactional: false,
2824                    }],
2825                );
2826            }
2827        }
2828
2829        // User-declared schema `Trigger`s — same previously-missing-parity
2830        // bug as deletion-policy triggers above: only `export_schema`'s
2831        // fresh-install path used to emit these at all (`emit_triggers` was
2832        // never called from the incremental migration path), so adding or
2833        // changing a `Trigger(...)` on an existing type via `migration
2834        // create` silently did nothing. A changed handler changes this
2835        // trigger's hash-derived name (`export::trigger_ddl_name`), so an
2836        // in-place edit surfaces here as "add the new name" — the stale
2837        // old-named trigger is caught by the generic "drop what's no longer
2838        // expected" sweep below, using the same `expected_trigger_map`.
2839        for info in crate::export::user_trigger_infos(target).map_err(|e| e.to_string())? {
2840            let cur = cur_trigger_map
2841                .get(&(info.table_module.as_str(), info.table_name.as_str()))
2842                .cloned()
2843                .unwrap_or_default();
2844            if !cur.contains(info.trigger_name.as_str()) {
2845                let (key, verb, desc) = owner_of(&info.table_module, &info.table_name);
2846                steps.extend(
2847                    key,
2848                    verb,
2849                    desc,
2850                    vec![DiffOp {
2851                        sql: info.ddl.clone(),
2852                        non_transactional: false,
2853                    }],
2854                );
2855            }
2856        }
2857
2858        // Cache-invalidation trigger — every concrete table and every
2859        // multi-link junction table, unconditionally (not gated by
2860        // `[cache].enabled`; see the cache layer plan's design decision).
2861        // Only the per-table attachment is migration content — the trigger
2862        // *function* itself ships via bootstrap DDL
2863        // (`stdlib::ddl::CACHE_INVALIDATE_DDL`), applied once by
2864        // `pylon database initialize`, not per-migration.
2865        // A HashSet, not a Vec — a junction ("through") type's own `td.table`
2866        // is the physical join table itself, identical to what the owning
2867        // type's multilink enumeration below also derives (e.g. `ProductTag`
2868        // and `Product`'s "tags" multilink both resolve to
2869        // `"Product.tags"`), so without deduping this would emit the same
2870        // `CREATE TRIGGER` statement twice for that table.
2871        let mut cache_trigger_tables: HashSet<(String, String)> = HashSet::new();
2872        for td in &target.types {
2873            if td.abstract_ || junction_table_belongs_to_an_interface(target, td) {
2874                continue;
2875            }
2876            cache_trigger_tables.insert((td.module.clone(), td.table.clone()));
2877            if !td.junction {
2878                for ml in &td.multilinks {
2879                    cache_trigger_tables.insert((td.module.clone(), format!("{}.{}", td.table, ml.name)));
2880                }
2881                for l in &td.links {
2882                    if !l.is_junction_backed() {
2883                        continue;
2884                    }
2885                    cache_trigger_tables.insert((td.module.clone(), format!("{}.{}", td.table, l.name)));
2886                }
2887            }
2888        }
2889        for (module, table) in &cache_trigger_tables {
2890            let already_present = cur_trigger_map
2891                .get(&(module.as_str(), table.as_str()))
2892                .map(|t| t.contains("pylon_cache_invalidate"))
2893                .unwrap_or(false);
2894            if !already_present {
2895                let (key, verb, desc) = owner_of(module, table);
2896                steps.extend(
2897                    key,
2898                    verb,
2899                    desc,
2900                    vec![DiffOp {
2901                        sql: cache_invalidate_trigger_sql(&qn(module, table)),
2902                        non_transactional: false,
2903                    }],
2904                );
2905            }
2906        }
2907
2908        // Drop triggers that no longer exist in the target schema — but not
2909        // for a table that's being dropped in its entirety (Phase 12): that
2910        // table's whole existence, triggers included, will be removed via
2911        // `DROP TABLE ... CASCADE`, so an explicit `DROP TRIGGER` here is
2912        // both redundant and actively wrong — `owner_of` has no real target
2913        // type to fold it into for such a table, so it fell back to a bare
2914        // `Verb::Alter` default, which then won the "first write wins" race
2915        // for this step's verb/prompt against Phase 12's own correct
2916        // `Verb::Drop` (confirmed live: `migration create` asked "did you
2917        // *alter* object type 'Widget'?" for a table being fully dropped).
2918        for cur_table in &current.tables {
2919            let key = (cur_table.schema.clone(), cur_table.name.clone());
2920            if !target_tables.contains(&key) {
2921                continue;
2922            }
2923            let expected = expected_trigger_map.get(&key).cloned().unwrap_or_default();
2924            for trigger_name in &cur_table.triggers {
2925                if !expected.contains(trigger_name) {
2926                    let (owner_key, verb, desc) = owner_of(&cur_table.schema, &cur_table.name);
2927                    steps.extend(
2928                        owner_key,
2929                        verb,
2930                        desc,
2931                        vec![
2932                            DiffOp {
2933                                sql: format!(
2934                                    "DROP TRIGGER IF EXISTS {} ON {};",
2935                                    qi(trigger_name),
2936                                    qn(&cur_table.schema, &cur_table.name)
2937                                ),
2938                                non_transactional: false,
2939                            },
2940                            // Generated triggers name their function after
2941                            // themselves, so dropping one leaves an
2942                            // unreferenced function behind. That was rare
2943                            // while trigger names were stable; now that a
2944                            // name follows its body (see `trigger_names` in
2945                            // `export`), every codegen change renames, and
2946                            // the orphans would accumulate — and read as
2947                            // live triggers to anyone introspecting.
2948                            //
2949                            // A no-op for triggers that don't follow that
2950                            // convention: the cache-invalidate trigger's
2951                            // function lives in `_pylon`, and the two
2952                            // interface-exclusive triggers (`..._ins`,
2953                            // `..._upd`) share one function named after
2954                            // neither. Nothing shared is reachable by this
2955                            // name, so this can't drop a function another
2956                            // trigger still needs.
2957                            DiffOp {
2958                                sql: format!("DROP FUNCTION IF EXISTS {}();", qn(&cur_table.schema, trigger_name)),
2959                                non_transactional: false,
2960                            },
2961                        ],
2962                    );
2963                }
2964            }
2965        }
2966    }
2967
2968    // ── Phase 11.9: drop CHECK constraints the schema no longer declares ─────
2969    // A check's name carries a hash of its own predicate, so editing one adds a
2970    // second constraint rather than replacing the first, and both then have to
2971    // hold — a column whose check was corrected would reject every value. Only
2972    // Pylon's own `…_<hash>_check` names are dropped; a constraint added by
2973    // hand is left alone.
2974    let expected_checks: HashSet<(String, String, String)> = crate::export::check_constraints(target)
2975        .map_err(|e| e.to_string())?
2976        .into_iter()
2977        .map(|(module, table, name, _)| (module, table, name))
2978        .collect();
2979    for cur_table in &current.tables {
2980        if !target_tables.contains(&(cur_table.schema.clone(), cur_table.name.clone())) {
2981            continue; // Phase 12 drops the whole table.
2982        }
2983        for check in &cur_table.checks {
2984            if !is_generated_check_name(&check.constraint_name) {
2985                continue;
2986            }
2987            let key = (
2988                cur_table.schema.clone(),
2989                cur_table.name.clone(),
2990                check.constraint_name.clone(),
2991            );
2992            if expected_checks.contains(&key) {
2993                continue;
2994            }
2995            steps.extend(
2996                OpKey::ForeignKey(cur_table.schema.clone(), format!("{}#checks", cur_table.name)),
2997                Verb::Alter,
2998                verbosename_type(&cur_table.schema, &cur_table.name),
2999                vec![DiffOp {
3000                    sql: format!(
3001                        "ALTER TABLE {} DROP CONSTRAINT IF EXISTS {};",
3002                        qn(&cur_table.schema, &cur_table.name),
3003                        qi(&check.constraint_name)
3004                    ),
3005                    non_transactional: false,
3006                }],
3007            );
3008        }
3009    }
3010
3011    // A link whose target became polymorphic (a concrete type gaining a
3012    // subtype) loses its foreign key: the subtype's rows live in another table.
3013    let polymorphic_targets = crate::export::polymorphic_types(target);
3014    let mut unwanted_fks: HashSet<(String, String, String)> = HashSet::new();
3015    for t in target.types.iter().filter(|t| !t.abstract_ && !t.junction) {
3016        for l in &t.links {
3017            if !polymorphic_targets.contains(&l.target) {
3018                continue;
3019            }
3020            unwanted_fks.insert(if l.is_junction_backed() {
3021                (
3022                    t.module.clone(),
3023                    format!("{}.{}", t.table, l.name),
3024                    format!("{}_{}_target_fkey", t.table, l.name),
3025                )
3026            } else {
3027                (
3028                    t.module.clone(),
3029                    t.table.clone(),
3030                    format!("{}_{}_fkey", t.table, l.name),
3031                )
3032            });
3033        }
3034        for ml in t
3035            .multilinks
3036            .iter()
3037            .filter(|ml| polymorphic_targets.contains(&ml.target))
3038        {
3039            unwanted_fks.insert((
3040                t.module.clone(),
3041                format!("{}.{}", t.table, ml.name),
3042                format!("{}_{}_target_fkey", t.table, ml.name),
3043            ));
3044        }
3045    }
3046    for cur_table in &current.tables {
3047        for fk in &cur_table.foreign_keys {
3048            let key = (
3049                cur_table.schema.clone(),
3050                cur_table.name.clone(),
3051                fk.constraint_name.clone(),
3052            );
3053            // A key into an interface's view was never created to begin with.
3054            let references_a_table = target
3055                .types
3056                .iter()
3057                .any(|t| !t.abstract_ && t.module == fk.ref_schema && t.table == fk.ref_table);
3058            if !unwanted_fks.contains(&key) || !references_a_table {
3059                continue;
3060            }
3061            steps.extend(
3062                OpKey::ForeignKey(cur_table.schema.clone(), cur_table.name.clone()),
3063                Verb::Alter,
3064                verbosename_type(&cur_table.schema, &cur_table.name),
3065                vec![DiffOp {
3066                    sql: format!(
3067                        "ALTER TABLE {} DROP CONSTRAINT IF EXISTS {};",
3068                        qn(&cur_table.schema, &cur_table.name),
3069                        qi(&fk.constraint_name)
3070                    ),
3071                    non_transactional: false,
3072                }],
3073            );
3074        }
3075    }
3076
3077    // ── Phase 12: drop removed tables ────────────────────────────────────────
3078    for cur_table in &current.tables {
3079        let key = (cur_table.schema.clone(), cur_table.name.clone());
3080        if !target_tables.contains(&key) {
3081            steps.push(
3082                OpKey::Table(cur_table.schema.clone(), cur_table.name.clone()),
3083                Verb::Drop,
3084                verbosename_type(&cur_table.schema, &cur_table.name),
3085                DiffOp {
3086                    sql: format!(
3087                        "DROP TABLE IF EXISTS {} CASCADE;",
3088                        qn(&cur_table.schema, &cur_table.name)
3089                    ),
3090                    non_transactional: false,
3091                },
3092            );
3093        }
3094    }
3095
3096    // ── Phase 13: drop removed enums ─────────────────────────────────────────
3097    let target_enum_set: HashSet<(String, String)> = target
3098        .enums
3099        .iter()
3100        .map(|e| (e.module.clone(), e.name.clone()))
3101        .collect();
3102    for cur_enum in &current.enums {
3103        if !target_enum_set.contains(&(cur_enum.schema.clone(), cur_enum.name.clone())) {
3104            steps.push(
3105                OpKey::Scalar(cur_enum.schema.clone(), cur_enum.name.clone()),
3106                Verb::Drop,
3107                verbosename_scalar(&cur_enum.schema, &cur_enum.name),
3108                DiffOp {
3109                    sql: format!(
3110                        "DROP TYPE IF EXISTS {}.{} CASCADE;",
3111                        pg_schema(&cur_enum.schema),
3112                        qi(&cur_enum.name)
3113                    ),
3114                    non_transactional: false,
3115                },
3116            );
3117        }
3118    }
3119
3120    // ── Phase 14: drop removed domains ───────────────────────────────────────
3121    let target_domain_set: HashSet<(String, String)> = target
3122        .scalars
3123        .iter()
3124        .map(|s| (s.module.clone(), s.name.clone()))
3125        .collect();
3126    for cur_domain in &current.domains {
3127        if !target_domain_set.contains(&(cur_domain.schema.clone(), cur_domain.name.clone())) {
3128            steps.push(
3129                OpKey::Scalar(cur_domain.schema.clone(), cur_domain.name.clone()),
3130                Verb::Drop,
3131                verbosename_scalar(&cur_domain.schema, &cur_domain.name),
3132                DiffOp {
3133                    sql: format!(
3134                        "DROP DOMAIN IF EXISTS {}.{} CASCADE;",
3135                        pg_schema(&cur_domain.schema),
3136                        qi(&cur_domain.name)
3137                    ),
3138                    non_transactional: false,
3139                },
3140            );
3141        }
3142    }
3143
3144    // ── Phase 14.5: drop removed sequences (folded into their scalar's drop
3145    // step by name, matching phase 2.5's own OpKey::Scalar grouping) ────────
3146    let target_sequence_set: HashSet<(String, String)> = target
3147        .scalars
3148        .iter()
3149        .filter(|s| s.is_sequence)
3150        .map(|s| (s.module.clone(), format!("{}_seq", s.name)))
3151        .collect();
3152    for cur_seq in &current.sequences {
3153        if !target_sequence_set.contains(&(cur_seq.schema.clone(), cur_seq.name.clone())) {
3154            let scalar_name = cur_seq.name.strip_suffix("_seq").unwrap_or(&cur_seq.name).to_string();
3155            steps.push(
3156                OpKey::Scalar(cur_seq.schema.clone(), scalar_name.clone()),
3157                Verb::Drop,
3158                verbosename_scalar(&cur_seq.schema, &scalar_name),
3159                DiffOp {
3160                    sql: format!(
3161                        "DROP SEQUENCE IF EXISTS {}.{};",
3162                        pg_schema(&cur_seq.schema),
3163                        qi(&cur_seq.name)
3164                    ),
3165                    non_transactional: false,
3166                },
3167            );
3168        }
3169    }
3170
3171    // ── Phase 15: drop removed modules ───────────────────────────────────────
3172    for module in &current.schemas {
3173        if module == "default" {
3174            continue;
3175        } // never drop public
3176        if !target_schemas.contains(module) {
3177            steps.push(
3178                OpKey::Module(module.clone()),
3179                Verb::Drop,
3180                verbosename_module(module),
3181                DiffOp {
3182                    sql: format!("DROP SCHEMA IF EXISTS {} CASCADE;", pg_schema(module)),
3183                    non_transactional: false,
3184                },
3185            );
3186        }
3187    }
3188
3189    Ok(steps.finish())
3190}
3191
3192fn push_tx(ops: &mut Vec<DiffOp>, sql: String) {
3193    ops.push(DiffOp {
3194        sql,
3195        non_transactional: false,
3196    });
3197}
3198
3199// ── Default resolution ────────────────────────────────────────────────────────
3200
3201/// Return the effective SQL DEFAULT for a property, compiling `default_pyql`
3202/// with the schema IR compiler if needed. `None` when a column DEFAULT cannot
3203/// hold it — `compile_insert` expands that one into the insert instead.
3204fn resolve_default(p: &crate::schema::PropertyDescriptor, schema: &SchemaDescriptor) -> Option<String> {
3205    if let Some(sql) = &p.default_sql {
3206        return Some(sql.clone());
3207    }
3208    if let Some(pyql) = &p.default_pyql {
3209        return crate::ir::column_default_sql(pyql, schema);
3210    }
3211    None
3212}
3213
3214/// Test-only accessor so `export` can assert its DDL matches what a
3215/// migration would produce for the same property.
3216#[cfg(test)]
3217pub(crate) fn resolve_default_for_test(
3218    p: &crate::schema::PropertyDescriptor,
3219    schema: &SchemaDescriptor,
3220) -> Option<String> {
3221    resolve_default(p, schema)
3222}
3223
3224fn resolve_link_default(l: &crate::schema::LinkDescriptor, schema: &SchemaDescriptor) -> Option<String> {
3225    if let Some(pyql) = &l.default_pyql {
3226        return crate::ir::column_default_sql(pyql, schema);
3227    }
3228    None
3229}
3230
3231// ── CREATE TABLE for a new type ───────────────────────────────────────────────
3232
3233fn emit_create_table(td: &TypeDescriptor, schema: &SchemaDescriptor, ops: &mut Vec<DiffOp>) {
3234    let mut lines: Vec<String> = Vec::new();
3235    for p in &td.properties {
3236        let not_null = if p.nullable { "" } else { " NOT NULL" };
3237        let default = resolve_default(p, schema)
3238            .map(|d| format!(" DEFAULT {}", d))
3239            .unwrap_or_default();
3240        lines.push(format!(
3241            "    {} {}{}{}",
3242            qi(&p.name),
3243            col_type_str(p),
3244            not_null,
3245            default
3246        ));
3247    }
3248    for l in &td.links {
3249        if l.is_junction_backed() {
3250            continue;
3251        }
3252        let not_null = if l.nullable { "" } else { " NOT NULL" };
3253        let default = resolve_link_default(l, schema)
3254            .map(|d| format!(" DEFAULT {}", d))
3255            .unwrap_or_default();
3256        lines.push(format!(
3257            "    {} uuid{}{}",
3258            qi(&format!("{}_id", l.name)),
3259            not_null,
3260            default
3261        ));
3262    }
3263    let pk_cols: Vec<String> = td.properties.iter().filter(|p| p.is_pk).map(|p| qi(&p.name)).collect();
3264    if !pk_cols.is_empty() {
3265        lines.push(format!("    PRIMARY KEY ({})", pk_cols.join(", ")));
3266    }
3267    push_tx(
3268        ops,
3269        format!(
3270            "CREATE TABLE IF NOT EXISTS {} (\n{}\n);",
3271            qn(&td.module, &td.table),
3272            lines.join(",\n")
3273        ),
3274    );
3275}
3276
3277/// `CREATE OR REPLACE TRIGGER` statement wiring `qualified_table` into the
3278/// cache-invalidation notify function (see `stdlib::ddl::CACHE_INVALIDATE_DDL`).
3279/// Statement-level, not row-level — tier 1 invalidation only needs one notify
3280/// per write statement.
3281fn cache_invalidate_trigger_sql(qualified_table: &str) -> String {
3282    format!(
3283        "CREATE OR REPLACE TRIGGER pylon_cache_invalidate\n    AFTER INSERT OR UPDATE OR DELETE ON {}\n    FOR EACH STATEMENT EXECUTE FUNCTION _pylon.notify_cache_invalidate();",
3284        qualified_table
3285    )
3286}
3287
3288// ── Column diff for an existing table ─────────────────────────────────────────
3289
3290/// `for_migration`: true = migration-file mode, false = watch mode.
3291/// `fill_cols`: column names that will be backfilled via a fill expression.
3292///   For migration mode, new NOT NULL columns in this set are added as nullable
3293///   first (the fill + SET NOT NULL comes later); in watch mode fills are unused.
3294fn emit_column_diff(
3295    td: &TypeDescriptor,
3296    existing: &DbTable,
3297    ops: &mut Vec<DiffOp>,
3298    for_migration: bool,
3299    fill_cols: &HashSet<String>,
3300    schema: &SchemaDescriptor,
3301    required_input: &mut Vec<RequiredInput>,
3302) {
3303    let existing_col_map: HashMap<&str, &DbColumn> = existing.columns.iter().map(|c| (c.name.as_str(), c)).collect();
3304
3305    // ── Add new columns ───────────────────────────────────────────────────────
3306    for p in &td.properties {
3307        if existing_col_map.contains_key(p.name.as_str()) {
3308            continue;
3309        }
3310        let eff_default = resolve_default(p, schema);
3311        let needs_fill = for_migration && !p.nullable && eff_default.is_none() && fill_cols.contains(&p.name);
3312        let not_null = if p.nullable || needs_fill { "" } else { " NOT NULL" };
3313        let default = eff_default.map(|d| format!(" DEFAULT {}", d)).unwrap_or_default();
3314        push_tx(
3315            ops,
3316            format!(
3317                "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} {}{}{};",
3318                qn(&td.module, &td.table),
3319                qi(&p.name),
3320                col_type_str(p),
3321                not_null,
3322                default
3323            ),
3324        );
3325    }
3326    for l in &td.links {
3327        if l.is_junction_backed() {
3328            continue;
3329        }
3330        let col = format!("{}_id", l.name);
3331        if existing_col_map.contains_key(col.as_str()) {
3332            continue;
3333        }
3334        let eff_default = resolve_link_default(l, schema);
3335        let needs_fill = for_migration && !l.nullable && eff_default.is_none() && fill_cols.contains(&col);
3336        let not_null = if l.nullable || needs_fill { "" } else { " NOT NULL" };
3337        let default = eff_default.map(|d| format!(" DEFAULT {}", d)).unwrap_or_default();
3338        push_tx(
3339            ops,
3340            format!(
3341                "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} uuid{}{};",
3342                qn(&td.module, &td.table),
3343                qi(&col),
3344                not_null,
3345                default
3346            ),
3347        );
3348    }
3349
3350    // ── Type changes on existing columns ──────────────────────────────────────
3351    // e.g. a property gaining a registered custom scalar's own DOMAIN (see
3352    // PropertyDescriptor.column_type), or any other base-type change.
3353    let type_changes: Vec<(&str, &str, bool)> = td
3354        .properties
3355        .iter()
3356        .filter_map(|p| {
3357            let cur = existing_col_map.get(p.name.as_str())?;
3358            if cur.is_generated {
3359                return None;
3360            }
3361            let target_type = col_type_str(p);
3362            pg_type_changed(target_type, &cur.pg_type).then_some((
3363                p.name.as_str(),
3364                target_type,
3365                cur.column_default.is_some(),
3366            ))
3367        })
3368        .collect();
3369    if !type_changes.is_empty() {
3370        // A column any interface view selects from can't be retyped directly
3371        // — Postgres refuses ALTER COLUMN TYPE while a view depends on it —
3372        // so drop those views first and recreate them (identical DDL,
3373        // unaffected by a column's own type) right after.
3374        let affected_views: Vec<(String, String, String)> = crate::export::interface_view_ddl_with_names(schema)
3375            .into_iter()
3376            .filter(|(m, n, _)| td.interfaces.contains(&format!("{}::{}", m, n)))
3377            .collect();
3378        for (m, n, _) in &affected_views {
3379            push_tx(ops, format!("DROP VIEW IF EXISTS {};", qn(m, n)));
3380        }
3381        for (col, target_type, has_default) in &type_changes {
3382            // Postgres checks the *existing* DEFAULT against the new type
3383            // before any `SET DEFAULT` further down can replace it, and
3384            // refuses ("default for column ... cannot be cast automatically").
3385            // Dropping it first leaves the column bare for exactly as long as
3386            // the type change takes; the new default is set below.
3387            if *has_default {
3388                push_tx(
3389                    ops,
3390                    format!(
3391                        "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT;",
3392                        qn(&td.module, &td.table),
3393                        qi(col)
3394                    ),
3395                );
3396            }
3397            // The conversion expression is a placeholder, not a blind cast —
3398            // an interactive caller offers `default_expr` (identical to what
3399            // this used to emit unconditionally) and lets the user override
3400            // it with their own PyQL expression before substituting it in;
3401            // `diff_schema_ops`/`watch` callers that never resolve any
3402            // `required_input` still get the exact same default behavior via
3403            // `RequiredInput::default_expr`-as-fallback at the CLI layer.
3404            let placeholder = format!("cast_expr__{col}");
3405            // PostgreSQL has no enum-to-enum cast, so a column moving between
3406            // two enums (a renamed one, most often) has to go through `text`.
3407            // Only for an enum target: a numeric conversion routed through
3408            // text would change what it accepts.
3409            let default_expr = if enum_target(schema, target_type) {
3410                let text_type = if target_type.ends_with("[]") { "text[]" } else { "text" };
3411                format!("{}::{text_type}::{target_type}", qi(col))
3412            } else {
3413                format!("{}::{target_type}", qi(col))
3414            };
3415            required_input.push(RequiredInput {
3416                placeholder: placeholder.clone(),
3417                prompt: format!(
3418                    "Please specify a conversion expression to alter the type of property '{col}' of {}",
3419                    verbosename_type(&td.module, &td.name),
3420                ),
3421                default_expr,
3422                type_name: format!("{}::{}", td.module, td.name),
3423            });
3424            push_tx(
3425                ops,
3426                format!(
3427                    "ALTER TABLE {} ALTER COLUMN {} TYPE {} USING \\({});",
3428                    qn(&td.module, &td.table),
3429                    qi(col),
3430                    target_type,
3431                    placeholder
3432                ),
3433            );
3434        }
3435        for (_, _, ddl) in &affected_views {
3436            push_tx(ops, ddl.clone());
3437        }
3438    }
3439
3440    // ── Nullability + DEFAULT changes on existing columns ─────────────────────
3441    for p in &td.properties {
3442        let Some(cur) = existing_col_map.get(p.name.as_str()) else {
3443            continue;
3444        };
3445        if cur.is_generated {
3446            continue;
3447        }
3448        if !cur.nullable && p.nullable {
3449            // NOT NULL → nullable: always safe, no fill needed.
3450            push_tx(
3451                ops,
3452                format!(
3453                    "ALTER TABLE {} ALTER COLUMN {} DROP NOT NULL;",
3454                    qn(&td.module, &td.table),
3455                    qi(&p.name)
3456                ),
3457            );
3458        } else if cur.nullable && !p.nullable && !for_migration {
3459            // nullable → NOT NULL: safe in watch mode (dev DB, typically no rows).
3460            push_tx(
3461                ops,
3462                format!(
3463                    "ALTER TABLE {} ALTER COLUMN {} SET NOT NULL;",
3464                    qn(&td.module, &td.table),
3465                    qi(&p.name)
3466                ),
3467            );
3468            // In migration mode this is intentionally skipped; the fill mechanism
3469            // emits UPDATE + SET NOT NULL after the main diff body.
3470        }
3471
3472        // DEFAULT changes
3473        let target_default = resolve_default(p, schema);
3474        let db_default = cur.column_default.as_deref();
3475        match (&target_default, db_default) {
3476            (Some(want), Some(have)) if want != have => {
3477                push_tx(
3478                    ops,
3479                    format!(
3480                        "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {};",
3481                        qn(&td.module, &td.table),
3482                        qi(&p.name),
3483                        want
3484                    ),
3485                );
3486            }
3487            (Some(want), None) => {
3488                push_tx(
3489                    ops,
3490                    format!(
3491                        "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {};",
3492                        qn(&td.module, &td.table),
3493                        qi(&p.name),
3494                        want
3495                    ),
3496                );
3497            }
3498            (None, Some(_)) => {
3499                push_tx(
3500                    ops,
3501                    format!(
3502                        "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT;",
3503                        qn(&td.module, &td.table),
3504                        qi(&p.name)
3505                    ),
3506                );
3507            }
3508            _ => {}
3509        }
3510    }
3511    for l in &td.links {
3512        if l.is_junction_backed() {
3513            continue;
3514        }
3515        let col = format!("{}_id", l.name);
3516        let Some(cur) = existing_col_map.get(col.as_str()) else {
3517            continue;
3518        };
3519        if !cur.nullable && l.nullable {
3520            push_tx(
3521                ops,
3522                format!(
3523                    "ALTER TABLE {} ALTER COLUMN {} DROP NOT NULL;",
3524                    qn(&td.module, &td.table),
3525                    qi(&col)
3526                ),
3527            );
3528        } else if cur.nullable && !l.nullable && !for_migration {
3529            push_tx(
3530                ops,
3531                format!(
3532                    "ALTER TABLE {} ALTER COLUMN {} SET NOT NULL;",
3533                    qn(&td.module, &td.table),
3534                    qi(&col)
3535                ),
3536            );
3537        }
3538
3539        let target_default = resolve_link_default(l, schema);
3540        let db_default = cur.column_default.as_deref();
3541        match (&target_default, db_default) {
3542            (Some(want), Some(have)) if want != have => {
3543                push_tx(
3544                    ops,
3545                    format!(
3546                        "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {};",
3547                        qn(&td.module, &td.table),
3548                        qi(&col),
3549                        want
3550                    ),
3551                );
3552            }
3553            (Some(want), None) => {
3554                push_tx(
3555                    ops,
3556                    format!(
3557                        "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {};",
3558                        qn(&td.module, &td.table),
3559                        qi(&col),
3560                        want
3561                    ),
3562                );
3563            }
3564            (None, Some(_)) => {
3565                push_tx(
3566                    ops,
3567                    format!(
3568                        "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT;",
3569                        qn(&td.module, &td.table),
3570                        qi(&col)
3571                    ),
3572                );
3573            }
3574            _ => {}
3575        }
3576    }
3577
3578    // ── Drop removed columns ──────────────────────────────────────────────────
3579    let target_cols: HashSet<String> = td
3580        .properties
3581        .iter()
3582        .map(|p| p.name.clone())
3583        .chain(
3584            td.links
3585                .iter()
3586                .filter(|l| !l.is_junction_backed())
3587                .map(|l| format!("{}_id", l.name)),
3588        )
3589        .collect();
3590    for col in &existing.columns {
3591        let n = col.name.as_str();
3592        if target_cols.contains(n) {
3593            continue;
3594        }
3595        if n.starts_with("__") && n.ends_with("__") {
3596            continue;
3597        }
3598        push_tx(
3599            ops,
3600            format!(
3601                "ALTER TABLE {} DROP COLUMN IF EXISTS {};",
3602                qn(&td.module, &td.table),
3603                qi(n)
3604            ),
3605        );
3606    }
3607}
3608
3609// ── FK diff for an existing table ─────────────────────────────────────────────
3610
3611fn emit_fk_diff(
3612    td: &TypeDescriptor,
3613    existing: Option<&DbTable>,
3614    type_map: &HashMap<String, (&str, &str)>,
3615    polymorphic: &HashSet<String>,
3616    ops: &mut Vec<DiffOp>,
3617) {
3618    use crate::schema::{DeleteAction, DeleteSide};
3619
3620    let existing_fk_names: HashSet<&str> = existing
3621        .map(|e| e.foreign_keys.iter().map(|fk| fk.constraint_name.as_str()).collect())
3622        .unwrap_or_default();
3623
3624    for l in &td.links {
3625        if l.is_junction_backed() {
3626            continue;
3627        }
3628        // Enforced by `export::interface_link_trigger_infos` instead: the
3629        // target is a view, and PostgreSQL will not reference one.
3630        if polymorphic.contains(&l.target) {
3631            continue;
3632        }
3633        let cname = format!("{}_{}_fkey", td.table, l.name);
3634        if existing_fk_names.contains(cname.as_str()) {
3635            continue;
3636        }
3637        let Some((tgt_module, tgt_table)) = type_map.get(&l.target) else {
3638            continue;
3639        };
3640        // See `export::needs_deferred_target_fk`'s doc comment — a
3641        // Source-side DeleteTarget/DeleteTargetIfOrphan trigger deletes the
3642        // target while the still-not-yet-removed owner row would otherwise
3643        // trip an immediate RESTRICT on this very FK (confirmed live via
3644        // `tests/live_execution_on_delete.rs`); force it deferrable so the
3645        // check only runs at commit, after both deletes have completed.
3646        let needs_deferred = crate::export::needs_deferred_target_fk(&l.on_delete);
3647        let on_delete = l
3648            .on_delete
3649            .iter()
3650            .find(|p| p.side == DeleteSide::Target)
3651            .map(|p| match &p.action {
3652                DeleteAction::Restrict if needs_deferred => " DEFERRABLE INITIALLY DEFERRED",
3653                DeleteAction::Restrict => " ON DELETE RESTRICT",
3654                DeleteAction::DeferredRestrict => " DEFERRABLE INITIALLY DEFERRED",
3655                DeleteAction::DeleteSource => " ON DELETE CASCADE",
3656                DeleteAction::Allow => " ON DELETE SET NULL",
3657                _ => " ON DELETE RESTRICT",
3658            })
3659            .unwrap_or(if needs_deferred {
3660                " DEFERRABLE INITIALLY DEFERRED"
3661            } else {
3662                " ON DELETE RESTRICT"
3663            });
3664        push_tx(
3665            ops,
3666            format!(
3667                "ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {}(id){};",
3668                qn(&td.module, &td.table),
3669                qi(&cname),
3670                qi(&format!("{}_id", l.name)),
3671                qn(tgt_module, tgt_table),
3672                on_delete
3673            ),
3674        );
3675    }
3676}
3677
3678// ── Junction table for a new multi-link ───────────────────────────────────────
3679
3680// Every parameter here is one independent axis of a junction table's shape
3681// (name, target, through-type, delete policy, cardinality), and they come
3682// from three different places in the caller. Bundling them into a
3683// params struct would move the same ten fields one level out without making
3684// any call site clearer.
3685#[allow(clippy::too_many_arguments)]
3686fn emit_junction_table(
3687    td: &TypeDescriptor,
3688    ml_name: &str,
3689    through: Option<&str>,
3690    schema: &SchemaDescriptor,
3691    single: bool,
3692    ops: &mut Vec<DiffOp>,
3693) {
3694    let jt_name = format!("{}.{}", td.table, ml_name);
3695    let src_on_delete = " ON DELETE CASCADE";
3696    // The target FK is added by the FK pass below, not inline — the target's
3697    // own table may not exist yet. See `export::junction_fk_constraints`.
3698    let mut col_lines = format!(
3699        "    source uuid NOT NULL REFERENCES {}(id){},\n    target uuid NOT NULL",
3700        qn(&td.module, &td.table),
3701        src_on_delete,
3702    );
3703
3704    // Extra columns from the through junction type.
3705    if let Some(through_qname) = through
3706        && let Some(through_td) = schema
3707            .types
3708            .iter()
3709            .find(|t| format!("{}::{}", t.module, t.name) == through_qname && t.junction)
3710    {
3711        for p in &through_td.properties {
3712            if p.name == "id" {
3713                continue;
3714            }
3715            let not_null = if p.nullable { "" } else { " NOT NULL" };
3716            col_lines.push_str(&format!(",\n    {} {}{}", qi(&p.name), col_type_str(p), not_null));
3717        }
3718    }
3719
3720    let pk_clause = if single {
3721        "PRIMARY KEY (source)"
3722    } else {
3723        "PRIMARY KEY (source, target)"
3724    };
3725    // An exclusive junction's `UNIQUE (target)` is left to the index pass,
3726    // which also adds it to a junction that already exists.
3727    push_tx(
3728        ops,
3729        format!(
3730            "CREATE TABLE IF NOT EXISTS {} (\n{},\n    {}\n);",
3731            qn(&td.module, &jt_name),
3732            col_lines,
3733            pk_clause,
3734        ),
3735    );
3736}
3737
3738// ── diff_states: diff two live-DB snapshots (for squash) ──────────────────────
3739
3740fn diff_states_inner(before: &DbState, after: &DbState) -> Vec<DiffOp> {
3741    let mut ops: Vec<DiffOp> = Vec::new();
3742
3743    let before_schemas: HashSet<&str> = before.schemas.iter().map(|s| s.as_str()).collect();
3744    let before_tables: HashMap<(&str, &str), &DbTable> = before
3745        .tables
3746        .iter()
3747        .map(|t| ((t.schema.as_str(), t.name.as_str()), t))
3748        .collect();
3749    let before_enums: HashMap<(&str, &str), &DbEnum> = before
3750        .enums
3751        .iter()
3752        .map(|e| ((e.schema.as_str(), e.name.as_str()), e))
3753        .collect();
3754    let before_domains: HashSet<(&str, &str)> = before
3755        .domains
3756        .iter()
3757        .map(|d| (d.schema.as_str(), d.name.as_str()))
3758        .collect();
3759
3760    // New schemas
3761    for schema in &after.schemas {
3762        if schema == "default" {
3763            continue;
3764        } // public always exists
3765        if !before_schemas.contains(schema.as_str()) {
3766            push_tx(&mut ops, format!("CREATE SCHEMA IF NOT EXISTS {};", pg_schema(schema)));
3767        }
3768    }
3769
3770    // New / altered enums
3771    for e in &after.enums {
3772        match before_enums.get(&(e.schema.as_str(), e.name.as_str())) {
3773            None => {
3774                let members: Vec<String> = e
3775                    .members
3776                    .iter()
3777                    .map(|m| format!("'{}'", m.replace('\'', "''")))
3778                    .collect();
3779                push_tx(
3780                    &mut ops,
3781                    format!(
3782                        "DO $$ BEGIN CREATE TYPE {}.{} AS ENUM ({}); \
3783                     EXCEPTION WHEN duplicate_object THEN NULL; END $$;",
3784                        pg_schema(&e.schema),
3785                        qi(&e.name),
3786                        members.join(", ")
3787                    ),
3788                );
3789            }
3790            Some(existing) => {
3791                let existing_set: HashSet<&str> = existing.members.iter().map(|m| m.as_str()).collect();
3792                for member in &e.members {
3793                    if !existing_set.contains(member.as_str()) {
3794                        push_tx(
3795                            &mut ops,
3796                            format!(
3797                                "ALTER TYPE {}.{} ADD VALUE IF NOT EXISTS '{}';",
3798                                pg_schema(&e.schema),
3799                                qi(&e.name),
3800                                member.replace('\'', "''")
3801                            ),
3802                        );
3803                    }
3804                }
3805            }
3806        }
3807    }
3808
3809    // New domains (simplified: no type reconstruction from DbState)
3810    for d in &after.domains {
3811        if !before_domains.contains(&(d.schema.as_str(), d.name.as_str())) {
3812            // We can't reconstruct the full domain DDL from pg_catalog cheaply;
3813            // emit a placeholder that will be filled by the squash command.
3814            push_tx(
3815                &mut ops,
3816                format!(
3817                    "-- TODO: recreate domain {}.{} (reconstruct DDL from source migrations)",
3818                    pg_schema(&d.schema),
3819                    qi(&d.name)
3820                ),
3821            );
3822        }
3823    }
3824
3825    // New tables
3826    let mut new_tables: HashSet<(String, String)> = HashSet::new();
3827    for t in &after.tables {
3828        let key = (t.schema.as_str(), t.name.as_str());
3829        match before_tables.get(&key) {
3830            None => {
3831                // Emit CREATE TABLE from introspected columns
3832                emit_create_table_from_db(t, &mut ops);
3833                new_tables.insert((t.schema.clone(), t.name.clone()));
3834            }
3835            Some(before_t) => {
3836                // Diff columns
3837                emit_column_diff_from_db(t, before_t, &mut ops);
3838            }
3839        }
3840    }
3841
3842    // FK constraints for existing tables that gained new FKs
3843    for t in &after.tables {
3844        if let Some(before_t) = before_tables.get(&(t.schema.as_str(), t.name.as_str())) {
3845            let before_fk_names: HashSet<&str> = before_t
3846                .foreign_keys
3847                .iter()
3848                .map(|fk| fk.constraint_name.as_str())
3849                .collect();
3850            for fk in &t.foreign_keys {
3851                if !before_fk_names.contains(fk.constraint_name.as_str()) {
3852                    // Can't fully reconstruct FK DDL from DbForeignKey without ON DELETE info;
3853                    // emit best-effort.
3854                    push_tx(
3855                        &mut ops,
3856                        format!(
3857                            "ALTER TABLE {}.{} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {}.{}(id);",
3858                            pg_schema(&t.schema),
3859                            qi(&t.name),
3860                            qi(&fk.constraint_name),
3861                            qi(&fk.local_column),
3862                            pg_schema(&fk.ref_schema),
3863                            qi(&fk.ref_table)
3864                        ),
3865                    );
3866                }
3867            }
3868        }
3869    }
3870
3871    // New indexes on pre-existing tables (CONCURRENTLY)
3872    for t in &after.tables {
3873        let table_is_new = new_tables.contains(&(t.schema.clone(), t.name.clone()));
3874        let before_indexes = before_tables
3875            .get(&(t.schema.as_str(), t.name.as_str()))
3876            .map(|bt| bt.indexes.as_slice())
3877            .unwrap_or_default();
3878        for idx in &t.indexes {
3879            // Postgres auto-names an index created without a name, so the
3880            // database's name for one never matches the name the schema side
3881            // made up. What settles whether it is already there is the key:
3882            // same columns, same uniqueness. An expression index has no
3883            // column list to compare and falls back to the name.
3884            let already_there = if idx.columns.is_empty() {
3885                before_indexes.iter().any(|b| b.name == idx.name)
3886            } else {
3887                before_indexes
3888                    .iter()
3889                    .any(|b| b.columns == idx.columns && b.is_unique == idx.is_unique)
3890            };
3891            if already_there {
3892                continue;
3893            }
3894            // Vector and search indexes are created by their own passes, which
3895            // know the operator class and expression each needs.
3896            if idx.columns.is_empty() {
3897                continue;
3898            }
3899            let use_concurrently = !table_is_new;
3900            let concurrently = if use_concurrently { "CONCURRENTLY " } else { "" };
3901            let unique = if idx.is_unique { "UNIQUE " } else { "" };
3902            let idx_sql = format!(
3903                "CREATE {unique}INDEX {concurrently}IF NOT EXISTS {} ON {}.{} ({});",
3904                qi(&idx.name),
3905                pg_schema(&t.schema),
3906                qi(&t.name),
3907                idx.columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", "),
3908            );
3909            ops.push(DiffOp {
3910                sql: idx_sql,
3911                non_transactional: use_concurrently,
3912            });
3913        }
3914    }
3915
3916    // Drop removed tables
3917    let after_tables: HashSet<(&str, &str)> = after
3918        .tables
3919        .iter()
3920        .map(|t| (t.schema.as_str(), t.name.as_str()))
3921        .collect();
3922    for t in &before.tables {
3923        if !after_tables.contains(&(t.schema.as_str(), t.name.as_str())) {
3924            push_tx(
3925                &mut ops,
3926                format!("DROP TABLE IF EXISTS {}.{} CASCADE;", pg_schema(&t.schema), qi(&t.name)),
3927            );
3928        }
3929    }
3930
3931    // Drop removed enums
3932    let after_enum_set: HashSet<(&str, &str)> = after
3933        .enums
3934        .iter()
3935        .map(|e| (e.schema.as_str(), e.name.as_str()))
3936        .collect();
3937    for e in &before.enums {
3938        if !after_enum_set.contains(&(e.schema.as_str(), e.name.as_str())) {
3939            push_tx(
3940                &mut ops,
3941                format!("DROP TYPE IF EXISTS {}.{} CASCADE;", pg_schema(&e.schema), qi(&e.name)),
3942            );
3943        }
3944    }
3945
3946    // Drop removed schemas
3947    let after_schema_set: HashSet<&str> = after.schemas.iter().map(|s| s.as_str()).collect();
3948    for schema in &before.schemas {
3949        if schema == "default" {
3950            continue;
3951        } // never drop public
3952        if !after_schema_set.contains(schema.as_str()) {
3953            push_tx(
3954                &mut ops,
3955                format!("DROP SCHEMA IF EXISTS {} CASCADE;", pg_schema(schema)),
3956            );
3957        }
3958    }
3959
3960    ops
3961}
3962
3963fn emit_create_table_from_db(t: &DbTable, ops: &mut Vec<DiffOp>) {
3964    let mut lines: Vec<String> = Vec::new();
3965    for col in &t.columns {
3966        let not_null = if col.nullable { "" } else { " NOT NULL" };
3967        if col.is_generated {
3968            // Can't reconstruct the generation expression from DbColumn alone.
3969            lines.push(format!(
3970                "    {} {} GENERATED ALWAYS AS (/* see source */) STORED",
3971                qi(&col.name),
3972                col.pg_type
3973            ));
3974        } else {
3975            lines.push(format!("    {} {}{}", qi(&col.name), col.pg_type, not_null));
3976        }
3977    }
3978    push_tx(
3979        ops,
3980        format!(
3981            "CREATE TABLE IF NOT EXISTS {}.{} (\n{}\n);",
3982            pg_schema(&t.schema),
3983            qi(&t.name),
3984            lines.join(",\n")
3985        ),
3986    );
3987}
3988
3989fn emit_column_diff_from_db(after: &DbTable, before: &DbTable, ops: &mut Vec<DiffOp>) {
3990    let before_cols: HashSet<&str> = before.columns.iter().map(|c| c.name.as_str()).collect();
3991    let after_cols: HashSet<&str> = after.columns.iter().map(|c| c.name.as_str()).collect();
3992
3993    for col in &after.columns {
3994        if !before_cols.contains(col.name.as_str()) {
3995            let not_null = if col.nullable { "" } else { " NOT NULL" };
3996            push_tx(
3997                ops,
3998                format!(
3999                    "ALTER TABLE {}.{} ADD COLUMN IF NOT EXISTS {} {}{};",
4000                    pg_schema(&after.schema),
4001                    qi(&after.name),
4002                    qi(&col.name),
4003                    col.pg_type,
4004                    not_null
4005                ),
4006            );
4007        }
4008    }
4009    for col in &before.columns {
4010        if !after_cols.contains(col.name.as_str()) {
4011            push_tx(
4012                ops,
4013                format!(
4014                    "ALTER TABLE {}.{} DROP COLUMN IF EXISTS {};",
4015                    pg_schema(&after.schema),
4016                    qi(&after.name),
4017                    qi(&col.name)
4018                ),
4019            );
4020        }
4021    }
4022}
4023
4024// ── Tests ─────────────────────────────────────────────────────────────────────
4025
4026#[cfg(test)]
4027mod tests {
4028    use super::*;
4029    use crate::schema::{EnumDescriptor, LinkDescriptor, PropertyDescriptor, SchemaDescriptor, TypeDescriptor};
4030
4031    fn empty_state() -> DbState {
4032        DbState::default()
4033    }
4034
4035    fn prop(name: &str, pg_type: &str, nullable: bool) -> PropertyDescriptor {
4036        PropertyDescriptor {
4037            name: name.into(),
4038            pg_type: pg_type.into(),
4039            nullable,
4040            default_sql: if name == "id" { Some("uuidv7()".into()) } else { None },
4041            default_pyql: None,
4042            description: None,
4043            check_constraints: vec![],
4044            is_exclusive: name == "id",
4045            is_pk: name == "id",
4046            is_readonly: name == "id",
4047            rewrites: vec![],
4048            tuple_members: None,
4049            column_type: None,
4050        }
4051    }
4052
4053    fn simple_type(module: &str, name: &str, table: &str) -> TypeDescriptor {
4054        TypeDescriptor {
4055            name: name.into(),
4056            module: module.into(),
4057            table: table.into(),
4058            abstract_: false,
4059            materialized: false,
4060            description: None,
4061            parents: vec![],
4062            interfaces: vec![],
4063            bases: vec![],
4064            properties: vec![prop("id", "uuid", false), prop("name", "text", true)],
4065            links: vec![],
4066            multilinks: vec![],
4067            computed: vec![],
4068            constraints: vec![],
4069            indexes: vec![],
4070            partition: None,
4071            vector_indexes: vec![],
4072            search_indexes: vec![],
4073            triggers: vec![],
4074            junction: false,
4075            signals: vec![],
4076        }
4077    }
4078
4079    // ── schema_content_changed ──────────────────────────────────────────────
4080
4081    #[test]
4082    fn test_schema_content_changed_detects_a_readonly_only_flip() {
4083        // The exact previously-broken case: `is_readonly` has zero DDL
4084        // footprint, so `diff_schema_steps`/`diff_schema_ops` alone would
4085        // never notice this change at all.
4086        let before = simple_type("default", "Person", "Person");
4087        let mut after = before.clone();
4088        after.properties[1].is_readonly = true; // "name"
4089        assert_ne!(before.properties[1].is_readonly, after.properties[1].is_readonly);
4090
4091        let schema_before = SchemaDescriptor {
4092            types: vec![before],
4093            scalars: vec![],
4094            enums: vec![],
4095            named_tuples: vec![],
4096            globals: vec![],
4097            functions: vec![],
4098            aliases: vec![],
4099            channels: vec![],
4100            ..Default::default()
4101        };
4102        let schema_after = SchemaDescriptor {
4103            types: vec![after],
4104            scalars: vec![],
4105            enums: vec![],
4106            named_tuples: vec![],
4107            globals: vec![],
4108            functions: vec![],
4109            aliases: vec![],
4110            channels: vec![],
4111            ..Default::default()
4112        };
4113        assert!(schema_content_changed(&schema_after, Some(&schema_before)));
4114    }
4115
4116    #[test]
4117    fn test_schema_content_changed_detects_a_new_rewrite() {
4118        let before = simple_type("default", "Person", "Person");
4119        let mut after = before.clone();
4120        after.properties[1].rewrites.push(crate::schema::RewriteEntry {
4121            on: 1,
4122            handler: "str_upper(.name)".into(),
4123        });
4124
4125        let schema_before = SchemaDescriptor {
4126            types: vec![before],
4127            scalars: vec![],
4128            enums: vec![],
4129            named_tuples: vec![],
4130            globals: vec![],
4131            functions: vec![],
4132            aliases: vec![],
4133            channels: vec![],
4134            ..Default::default()
4135        };
4136        let schema_after = SchemaDescriptor {
4137            types: vec![after],
4138            scalars: vec![],
4139            enums: vec![],
4140            named_tuples: vec![],
4141            globals: vec![],
4142            functions: vec![],
4143            aliases: vec![],
4144            channels: vec![],
4145            ..Default::default()
4146        };
4147        assert!(schema_content_changed(&schema_after, Some(&schema_before)));
4148    }
4149
4150    #[test]
4151    fn test_schema_content_changed_is_false_for_identical_schemas() {
4152        let t = simple_type("default", "Person", "Person");
4153        let schema = SchemaDescriptor {
4154            types: vec![t],
4155            scalars: vec![],
4156            enums: vec![],
4157            named_tuples: vec![],
4158            globals: vec![],
4159            functions: vec![],
4160            aliases: vec![],
4161            channels: vec![],
4162            ..Default::default()
4163        };
4164        let other = schema.clone();
4165        assert!(!schema_content_changed(&schema, Some(&other)));
4166    }
4167
4168    #[test]
4169    fn test_schema_content_changed_true_against_none_when_target_is_non_empty() {
4170        let t = simple_type("default", "Person", "Person");
4171        let schema = SchemaDescriptor {
4172            types: vec![t],
4173            scalars: vec![],
4174            enums: vec![],
4175            named_tuples: vec![],
4176            globals: vec![],
4177            functions: vec![],
4178            aliases: vec![],
4179            channels: vec![],
4180            ..Default::default()
4181        };
4182        assert!(
4183            schema_content_changed(&schema, None),
4184            "no prior snapshot at all must count as changed"
4185        );
4186    }
4187
4188    #[test]
4189    fn test_schema_content_changed_false_against_none_when_target_is_also_empty() {
4190        let schema = SchemaDescriptor::default();
4191        assert!(!schema_content_changed(&schema, None));
4192    }
4193
4194    #[test]
4195    fn test_schema_content_changed_still_true_when_ddl_visible_things_also_changed() {
4196        // A DDL-visible change (a whole new type) must also register —
4197        // this function is meant to be OR'd with diff_schema_steps's own
4198        // result, not treated as mutually exclusive with it.
4199        let schema_before = SchemaDescriptor::default();
4200        let schema_after = SchemaDescriptor {
4201            types: vec![simple_type("default", "Person", "Person")],
4202            scalars: vec![],
4203            enums: vec![],
4204            named_tuples: vec![],
4205            globals: vec![],
4206            functions: vec![],
4207            aliases: vec![],
4208            channels: vec![],
4209            ..Default::default()
4210        };
4211        assert!(schema_content_changed(&schema_after, Some(&schema_before)));
4212    }
4213
4214    #[test]
4215    fn test_schema_content_changed_detects_a_new_channel() {
4216        // A `Channel` has zero physical DDL footprint — nothing about it
4217        // ever shows up in `diff_schema_steps`/`diff_schema_ops`, so its
4218        // mere addition is exactly the class of change this function
4219        // exists to catch, same as the readonly/rewrite cases above.
4220        let schema_before = SchemaDescriptor::default();
4221        let schema_after = SchemaDescriptor {
4222            channels: vec![crate::schema::ChannelDescriptor {
4223                name: "UserUpdates".into(),
4224                module: "default".into(),
4225                wire_name: "default__user_updates".into(),
4226                payload: crate::schema::ChannelPayload::Scalar("text".into()),
4227                description: None,
4228            }],
4229            ..Default::default()
4230        };
4231        assert!(schema_content_changed(&schema_after, Some(&schema_before)));
4232    }
4233
4234    #[test]
4235    fn test_new_schema_and_table() {
4236        let schema = SchemaDescriptor {
4237            types: vec![simple_type("catalog", "Product", "Product")],
4238            scalars: vec![],
4239            enums: vec![],
4240            named_tuples: vec![],
4241            globals: vec![],
4242            functions: vec![],
4243            aliases: vec![],
4244            channels: vec![],
4245            ..Default::default()
4246        };
4247        let ops = diff_schema(&schema, &empty_state()).unwrap();
4248        let joined = ops.join("\n");
4249        assert!(
4250            joined.contains("CREATE SCHEMA IF NOT EXISTS \"catalog\""),
4251            "got:\n{joined}"
4252        );
4253        assert!(
4254            joined.contains("CREATE TABLE IF NOT EXISTS \"catalog\".\"Product\""),
4255            "got:\n{joined}"
4256        );
4257        assert!(
4258            joined.contains("CREATE OR REPLACE TRIGGER pylon_cache_invalidate\n    AFTER INSERT OR UPDATE OR DELETE ON \"catalog\".\"Product\""),
4259            "new table must get the cache-invalidation trigger; got:\n{joined}"
4260        );
4261    }
4262
4263    fn widget_with_trigger(on: u8, timing: &str, handler: &str) -> TypeDescriptor {
4264        let mut t = simple_type("default", "Widget", "Widget");
4265        t.triggers = vec![crate::schema::TriggerDescriptor {
4266            on,
4267            timing: timing.into(),
4268            handler: handler.into(),
4269        }];
4270        t
4271    }
4272
4273    #[test]
4274    fn test_new_table_with_user_trigger_emits_the_compiled_trigger_ddl() {
4275        // Regression guard for the previously-missing incremental-migration
4276        // path: `export_schema` always emitted a schema's user-declared
4277        // `Trigger`s, but `diff_schema`/`diff_schema_steps` (the path
4278        // `migration create` actually uses) never did, so adding a
4279        // `Trigger(...)` to an existing type had zero effect on a real
4280        // migration.
4281        let schema = SchemaDescriptor {
4282            types: vec![widget_with_trigger(
4283                1,
4284                "After",
4285                "update Widget set { name := __new__.name }",
4286            )],
4287            scalars: vec![],
4288            enums: vec![],
4289            named_tuples: vec![],
4290            globals: vec![],
4291            functions: vec![],
4292            aliases: vec![],
4293            channels: vec![],
4294            ..Default::default()
4295        };
4296        let ops = diff_schema(&schema, &empty_state()).unwrap();
4297        let joined = ops.join("\n");
4298        assert!(joined.contains("NEW.\"name\""), "got:\n{joined}");
4299    }
4300
4301    #[test]
4302    fn a_trigger_emitted_by_an_older_build_is_replaced() {
4303        // The delivery half of content-addressed trigger names: because a
4304        // trigger's name now hashes its body, a database whose trigger was
4305        // written by an older emitter carries a *different* name than the
4306        // current one, and that difference is what the diff can see. Before
4307        // this, the name was derived from the table and pointer alone, so a
4308        // fix to trigger codegen produced no diff at all and the old body
4309        // stayed until the table was dropped.
4310        //
4311        // Simulated by renaming the trigger in the baseline, which is
4312        // exactly what an emitter change looks like from the diff's side.
4313        let schema = SchemaDescriptor {
4314            types: vec![widget_with_trigger(
4315                1,
4316                "After",
4317                "update Widget set { name := __new__.name }",
4318            )],
4319            scalars: vec![],
4320            enums: vec![],
4321            named_tuples: vec![],
4322            globals: vec![],
4323            functions: vec![],
4324            aliases: vec![],
4325            channels: vec![],
4326            ..Default::default()
4327        };
4328        let mut stale = schema_to_db_state(&schema);
4329        let current_name = stale
4330            .tables
4331            .iter()
4332            .flat_map(|t| t.triggers.iter().cloned())
4333            .find(|n| n.starts_with("Widget_"))
4334            .expect("the fixture should project a Widget trigger");
4335        let stale_name = "Widget_trg_0badc0de".to_string();
4336        for table in &mut stale.tables {
4337            for trigger in &mut table.triggers {
4338                if *trigger == current_name {
4339                    *trigger = stale_name.clone();
4340                }
4341            }
4342        }
4343
4344        let joined = diff_schema(&schema, &stale).unwrap().join("\n");
4345        assert!(
4346            joined.contains(&format!("DROP TRIGGER IF EXISTS \"{stale_name}\"")),
4347            "the stale trigger should be dropped, got:\n{joined}"
4348        );
4349        assert!(
4350            joined.contains(&current_name),
4351            "the current trigger should be created, got:\n{joined}"
4352        );
4353        // The stale trigger's function goes with it — a rename would
4354        // otherwise leave a dead function behind on every codegen change.
4355        assert!(
4356            joined.contains(&format!("DROP FUNCTION IF EXISTS \"public\".\"{stale_name}\"()")),
4357            "the orphaned function should be dropped, got:\n{joined}"
4358        );
4359    }
4360
4361    #[test]
4362    fn test_user_trigger_already_present_in_offline_baseline_produces_no_further_steps() {
4363        // Same "phantom step" bug class fixed earlier for cache-invalidate/
4364        // signal triggers: `schema_to_db_state`'s projection must recognize
4365        // a user Trigger as already present, or `migration create` would
4366        // propose recreating it forever, even with zero real schema changes.
4367        let schema = SchemaDescriptor {
4368            types: vec![widget_with_trigger(
4369                1,
4370                "After",
4371                "update Widget set { name := __new__.name }",
4372            )],
4373            scalars: vec![],
4374            enums: vec![],
4375            named_tuples: vec![],
4376            globals: vec![],
4377            functions: vec![],
4378            aliases: vec![],
4379            channels: vec![],
4380            ..Default::default()
4381        };
4382        let baseline = schema_to_db_state(&schema);
4383        let steps = diff_schema_steps(&schema, &baseline, &HashMap::new()).unwrap();
4384        assert!(
4385            steps.is_empty(),
4386            "expected zero further migration steps, got: {steps:?}"
4387        );
4388    }
4389
4390    #[test]
4391    fn a_link_target_gaining_a_subtype_loses_its_foreign_key() {
4392        let mut order = simple_type("default", "Order", "Order");
4393        order.links.push(LinkDescriptor {
4394            name: "customer".into(),
4395            target: "default::Person".into(),
4396            nullable: false,
4397            through: None,
4398            description: None,
4399            default_pyql: None,
4400            is_exclusive: false,
4401            is_readonly: false,
4402            rewrites: vec![],
4403            on_delete: vec![],
4404        });
4405        let before = SchemaDescriptor {
4406            types: vec![order, simple_type("default", "Person", "Person")],
4407            scalars: vec![],
4408            enums: vec![],
4409            named_tuples: vec![],
4410            globals: vec![],
4411            functions: vec![],
4412            aliases: vec![],
4413            channels: vec![],
4414            ..Default::default()
4415        };
4416        let mut after = before.clone();
4417        let mut vip = simple_type("default", "Vip", "Vip");
4418        vip.bases = vec!["default::Person".into()];
4419        after.types.push(vip);
4420
4421        let joined = diff_schema(&after, &schema_to_db_state(&before)).unwrap().join("\n");
4422        assert!(
4423            joined.contains("ALTER TABLE \"public\".\"Order\" DROP CONSTRAINT IF EXISTS \"Order_customer_fkey\""),
4424            "got:\n{joined}"
4425        );
4426        let settled = diff_schema_steps(&after, &schema_to_db_state(&after), &HashMap::new()).unwrap();
4427        assert!(settled.is_empty(), "nothing further once applied, got: {settled:?}");
4428    }
4429
4430    #[test]
4431    fn test_new_table_with_plain_link_gets_its_fk_constraint() {
4432        // Regression guard: a brand-new table's `emit_create_table` only ever
4433        // emits a plain `uuid` column for a single link — the FK constraint
4434        // itself comes from Phase 6 (`emit_fk_diff`), which used to run only
4435        // for tables already present in `cur_tables`, silently skipping every
4436        // link on a table created in the same diff pass.
4437        let mut order = simple_type("default", "Order", "Order");
4438        order.links.push(LinkDescriptor {
4439            name: "customer".into(),
4440            target: "default::Person".into(),
4441            nullable: false,
4442            through: None,
4443            description: None,
4444            default_pyql: None,
4445            is_exclusive: false,
4446            is_readonly: false,
4447            rewrites: vec![],
4448            on_delete: vec![],
4449        });
4450        let schema = SchemaDescriptor {
4451            types: vec![order, simple_type("default", "Person", "Person")],
4452            scalars: vec![],
4453            enums: vec![],
4454            named_tuples: vec![],
4455            globals: vec![],
4456            functions: vec![],
4457            aliases: vec![],
4458            channels: vec![],
4459            ..Default::default()
4460        };
4461        let ops = diff_schema(&schema, &empty_state()).unwrap();
4462        let joined = ops.join("\n");
4463        assert!(
4464            joined.contains("ADD CONSTRAINT \"Order_customer_fkey\" FOREIGN KEY (\"customer_id\") REFERENCES \"public\".\"Person\"(id)"),
4465            "new table's plain link must get its FK constraint in the same diff; got:\n{joined}"
4466        );
4467    }
4468
4469    #[test]
4470    fn test_an_existing_exclusive_multilink_junction_gains_its_unique_target() {
4471        use crate::schema::MultiLinkDescriptor;
4472
4473        let mut person = simple_type("default", "Person", "Person");
4474        person.multilinks.push(MultiLinkDescriptor {
4475            name: "keys".into(),
4476            target: "default::Key".into(),
4477            through: None,
4478            nullable: false,
4479            description: None,
4480            default_pyql: None,
4481            on_delete: vec![],
4482            is_exclusive: true,
4483        });
4484        let schema = SchemaDescriptor {
4485            types: vec![person, simple_type("default", "Key", "Key")],
4486            scalars: vec![],
4487            enums: vec![],
4488            named_tuples: vec![],
4489            globals: vec![],
4490            functions: vec![],
4491            aliases: vec![],
4492            channels: vec![],
4493            ..Default::default()
4494        };
4495        let mut state = schema_to_db_state(&schema);
4496        assert!(diff_schema(&schema, &state).unwrap().is_empty());
4497
4498        for table in state.tables.iter_mut().filter(|t| t.name == "Person.keys") {
4499            table.indexes.clear();
4500        }
4501        let joined = diff_schema(&schema, &state).unwrap().join("\n");
4502        assert!(
4503            joined.contains("CREATE UNIQUE INDEX IF NOT EXISTS \"Person.keys_target_key\" ON \"public\".\"Person.keys\" (\"target\")"),
4504            "got:\n{joined}"
4505        );
4506    }
4507
4508    #[test]
4509    fn test_cache_invalidate_trigger_not_duplicated_for_junction_through_type() {
4510        use crate::schema::MultiLinkDescriptor;
4511
4512        let mut product = simple_type("default", "Product", "Product");
4513        product.multilinks.push(MultiLinkDescriptor {
4514            name: "tags".into(),
4515            target: "default::Tag".into(),
4516            through: Some("default::ProductTag".into()),
4517            nullable: false,
4518            description: None,
4519            default_pyql: None,
4520            on_delete: vec![],
4521            is_exclusive: false,
4522        });
4523        let mut junction = simple_type("default", "ProductTag", "Product.tags");
4524        junction.junction = true;
4525
4526        let schema = SchemaDescriptor {
4527            types: vec![product, junction, simple_type("default", "Tag", "Tag")],
4528            scalars: vec![],
4529            enums: vec![],
4530            named_tuples: vec![],
4531            globals: vec![],
4532            functions: vec![],
4533            aliases: vec![],
4534            channels: vec![],
4535            ..Default::default()
4536        };
4537        let ops = diff_schema(&schema, &empty_state()).unwrap();
4538        let trigger_count = ops
4539            .iter()
4540            .filter(|op| op.contains("AFTER INSERT OR UPDATE OR DELETE ON \"public\".\"Product.tags\""))
4541            .count();
4542        assert_eq!(
4543            trigger_count, 1,
4544            "junction table's own td.table and the owning type's multilink both resolve to \
4545             the same physical table — must be deduped to one trigger, got {trigger_count} in: {ops:?}"
4546        );
4547    }
4548
4549    fn person_with_junction_backed_spouse() -> SchemaDescriptor {
4550        let mut person = simple_type("default", "Person", "Person");
4551        person.links.push(LinkDescriptor {
4552            name: "spouse".into(),
4553            target: "default::Person".into(),
4554            nullable: true,
4555            through: Some("default::Marriage".into()),
4556            description: None,
4557            default_pyql: None,
4558            is_exclusive: true,
4559            is_readonly: false,
4560            rewrites: vec![],
4561            on_delete: vec![],
4562        });
4563        let mut junction = simple_type("default", "Marriage", "Person.spouse");
4564        junction.junction = true;
4565
4566        SchemaDescriptor {
4567            types: vec![person, junction],
4568            scalars: vec![],
4569            enums: vec![],
4570            named_tuples: vec![],
4571            globals: vec![],
4572            functions: vec![],
4573            aliases: vec![],
4574            channels: vec![],
4575            ..Default::default()
4576        }
4577    }
4578
4579    #[test]
4580    fn test_junction_backed_single_link_creates_junction_table_from_scratch() {
4581        let schema = person_with_junction_backed_spouse();
4582        let ops = diff_schema(&schema, &empty_state()).unwrap();
4583        let joined = ops.join("\n");
4584        assert!(
4585            !joined.contains("spouse_id"),
4586            "no {{name}}_id column/FK for a junction-backed link, got:\n{joined}"
4587        );
4588        assert!(
4589            joined.contains("CREATE TABLE IF NOT EXISTS \"public\".\"Person.spouse\""),
4590            "got:\n{joined}"
4591        );
4592        assert!(joined.contains("PRIMARY KEY (source)"), "got:\n{joined}");
4593        assert!(
4594            joined.contains("CREATE UNIQUE INDEX IF NOT EXISTS \"Person.spouse_target_key\" ON \"public\".\"Person.spouse\" (\"target\")"),
4595            "got:\n{joined}"
4596        );
4597    }
4598
4599    #[test]
4600    fn test_junction_backed_single_link_diff_is_idempotent_once_applied() {
4601        // Regression guard for the drop-detection gap called out in the
4602        // junction-backed-single-link plan: without including link-through
4603        // junction tables in the create-loop/drop-detection/cache-trigger
4604        // sets, the table would get created then immediately flagged for
4605        // drop as "unknown" on the very next diff. This models the DbState
4606        // a live Postgres introspection would report right after the
4607        // create-from-scratch ops above were actually applied.
4608        let schema = person_with_junction_backed_spouse();
4609        let state = DbState {
4610            schemas: vec![],
4611            tables: vec![
4612                DbTable {
4613                    schema: "default".into(),
4614                    name: "Person".into(),
4615                    columns: vec![
4616                        DbColumn {
4617                            name: "id".into(),
4618                            pg_type: "uuid".into(),
4619                            nullable: false,
4620                            is_generated: false,
4621                            column_default: Some("uuidv7()".into()),
4622                        },
4623                        DbColumn {
4624                            name: "name".into(),
4625                            pg_type: "text".into(),
4626                            nullable: true,
4627                            is_generated: false,
4628                            column_default: None,
4629                        },
4630                    ],
4631                    foreign_keys: vec![],
4632                    indexes: vec![],
4633                    checks: vec![],
4634                    triggers: vec!["pylon_cache_invalidate".into()],
4635                },
4636                DbTable {
4637                    schema: "default".into(),
4638                    name: "Person.spouse".into(),
4639                    columns: vec![
4640                        DbColumn {
4641                            name: "source".into(),
4642                            pg_type: "uuid".into(),
4643                            nullable: false,
4644                            is_generated: false,
4645                            column_default: None,
4646                        },
4647                        DbColumn {
4648                            name: "target".into(),
4649                            pg_type: "uuid".into(),
4650                            nullable: false,
4651                            is_generated: false,
4652                            column_default: None,
4653                        },
4654                        DbColumn {
4655                            name: "name".into(),
4656                            pg_type: "text".into(),
4657                            nullable: true,
4658                            is_generated: false,
4659                            column_default: None,
4660                        },
4661                    ],
4662                    foreign_keys: vec![
4663                        DbForeignKey {
4664                            constraint_name: "Person_spouse_source_fkey".into(),
4665                            local_column: "source".into(),
4666                            ref_schema: "default".into(),
4667                            ref_table: "Person".into(),
4668                        },
4669                        DbForeignKey {
4670                            constraint_name: "Person_spouse_target_fkey".into(),
4671                            local_column: "target".into(),
4672                            ref_schema: "default".into(),
4673                            ref_table: "Person".into(),
4674                        },
4675                    ],
4676                    indexes: vec![DbIndex {
4677                        name: "Person.spouse_target_key".into(),
4678                        is_unique: true,
4679                        method: "btree".into(),
4680                        columns: vec!["target".into()],
4681                        predicate: None,
4682                        key: None,
4683                    }],
4684                    checks: vec![],
4685                    triggers: vec!["pylon_cache_invalidate".into()],
4686                },
4687            ],
4688            enums: vec![],
4689            domains: vec![],
4690            ..DbState::default()
4691        };
4692        let ops = diff_schema(&schema, &state).unwrap();
4693        assert!(
4694            ops.is_empty(),
4695            "already-migrated junction-backed single link must diff to no ops, got: {:?}",
4696            ops
4697        );
4698    }
4699
4700    #[test]
4701    fn test_cache_invalidate_trigger_backfilled_on_pre_existing_table() {
4702        let schema = SchemaDescriptor {
4703            types: vec![simple_type("default", "Person", "Person")],
4704            scalars: vec![],
4705            enums: vec![],
4706            named_tuples: vec![],
4707            globals: vec![],
4708            functions: vec![],
4709            aliases: vec![],
4710            channels: vec![],
4711            ..Default::default()
4712        };
4713        let state = DbState {
4714            schemas: vec!["default".into()],
4715            tables: vec![DbTable {
4716                schema: "default".into(),
4717                name: "Person".into(),
4718                columns: vec![
4719                    DbColumn {
4720                        name: "id".into(),
4721                        pg_type: "uuid".into(),
4722                        nullable: false,
4723                        is_generated: false,
4724                        column_default: Some("uuidv7()".into()),
4725                    },
4726                    DbColumn {
4727                        name: "name".into(),
4728                        pg_type: "text".into(),
4729                        nullable: true,
4730                        is_generated: false,
4731                        column_default: None,
4732                    },
4733                ],
4734                foreign_keys: vec![],
4735                indexes: vec![],
4736                checks: vec![],
4737                triggers: vec![], // pre-existing table, created before this feature shipped
4738            }],
4739            enums: vec![],
4740            domains: vec![],
4741            ..DbState::default()
4742        };
4743        let ops = diff_schema(&schema, &state).unwrap();
4744        let joined = ops.join("\n");
4745        assert!(
4746            joined.contains("CREATE OR REPLACE TRIGGER pylon_cache_invalidate\n    AFTER INSERT OR UPDATE OR DELETE ON \"public\".\"Person\""),
4747            "pre-existing table missing the trigger must get it backfilled; got:\n{joined}"
4748        );
4749    }
4750
4751    #[test]
4752    fn test_cache_invalidate_trigger_not_dropped_when_already_present() {
4753        let schema = SchemaDescriptor {
4754            types: vec![simple_type("default", "Person", "Person")],
4755            scalars: vec![],
4756            enums: vec![],
4757            named_tuples: vec![],
4758            globals: vec![],
4759            functions: vec![],
4760            aliases: vec![],
4761            channels: vec![],
4762            ..Default::default()
4763        };
4764        let state = DbState {
4765            schemas: vec!["default".into()],
4766            tables: vec![DbTable {
4767                schema: "default".into(),
4768                name: "Person".into(),
4769                columns: vec![
4770                    DbColumn {
4771                        name: "id".into(),
4772                        pg_type: "uuid".into(),
4773                        nullable: false,
4774                        is_generated: false,
4775                        column_default: Some("uuidv7()".into()),
4776                    },
4777                    DbColumn {
4778                        name: "name".into(),
4779                        pg_type: "text".into(),
4780                        nullable: true,
4781                        is_generated: false,
4782                        column_default: None,
4783                    },
4784                ],
4785                foreign_keys: vec![],
4786                indexes: vec![],
4787                checks: vec![],
4788                triggers: vec!["pylon_cache_invalidate".into()],
4789            }],
4790            enums: vec![],
4791            domains: vec![],
4792            ..DbState::default()
4793        };
4794        let ops = diff_schema(&schema, &state).unwrap();
4795        assert!(
4796            ops.iter()
4797                .all(|op| !op.contains("DROP TRIGGER") && !op.contains("pylon_cache_invalidate")),
4798            "already-present trigger must not be re-created or dropped; got: {:?}",
4799            ops
4800        );
4801    }
4802
4803    #[test]
4804    fn test_no_ops_when_in_sync() {
4805        let schema = SchemaDescriptor {
4806            types: vec![simple_type("default", "Person", "Person")],
4807            scalars: vec![],
4808            enums: vec![],
4809            named_tuples: vec![],
4810            globals: vec![],
4811            functions: vec![],
4812            aliases: vec![],
4813            channels: vec![],
4814            ..Default::default()
4815        };
4816        let state = DbState {
4817            schemas: vec!["default".into()],
4818            tables: vec![DbTable {
4819                schema: "default".into(),
4820                name: "Person".into(),
4821                columns: vec![
4822                    DbColumn {
4823                        name: "id".into(),
4824                        pg_type: "uuid".into(),
4825                        nullable: false,
4826                        is_generated: false,
4827                        column_default: Some("uuidv7()".into()),
4828                    },
4829                    DbColumn {
4830                        name: "name".into(),
4831                        pg_type: "text".into(),
4832                        nullable: true,
4833                        is_generated: false,
4834                        column_default: None,
4835                    },
4836                ],
4837                foreign_keys: vec![],
4838                indexes: vec![],
4839                checks: vec![],
4840                triggers: vec!["pylon_cache_invalidate".into()],
4841            }],
4842            enums: vec![],
4843            domains: vec![],
4844            ..DbState::default()
4845        };
4846        let ops = diff_schema(&schema, &state).unwrap();
4847        assert!(ops.is_empty(), "expected no ops, got: {:?}", ops);
4848    }
4849
4850    #[test]
4851    fn test_add_column() {
4852        let mut td = simple_type("default", "Person", "Person");
4853        td.properties.push(prop("email", "text", true));
4854        let schema = SchemaDescriptor {
4855            types: vec![td],
4856            scalars: vec![],
4857            enums: vec![],
4858            named_tuples: vec![],
4859            globals: vec![],
4860            functions: vec![],
4861            aliases: vec![],
4862            channels: vec![],
4863            ..Default::default()
4864        };
4865        let state = DbState {
4866            schemas: vec!["default".into()],
4867            tables: vec![DbTable {
4868                schema: "default".into(),
4869                name: "Person".into(),
4870                columns: vec![
4871                    DbColumn {
4872                        name: "id".into(),
4873                        pg_type: "uuid".into(),
4874                        nullable: false,
4875                        is_generated: false,
4876                        column_default: Some("uuidv7()".into()),
4877                    },
4878                    DbColumn {
4879                        name: "name".into(),
4880                        pg_type: "text".into(),
4881                        nullable: true,
4882                        is_generated: false,
4883                        column_default: None,
4884                    },
4885                ],
4886                foreign_keys: vec![],
4887                indexes: vec![],
4888                checks: vec![],
4889                triggers: vec![],
4890            }],
4891            enums: vec![],
4892            domains: vec![],
4893            ..DbState::default()
4894        };
4895        let ops = diff_schema(&schema, &state).unwrap();
4896        let joined = ops.join("\n");
4897        assert!(joined.contains("ADD COLUMN IF NOT EXISTS \"email\""), "got:\n{joined}");
4898    }
4899
4900    #[test]
4901    fn test_property_type_change_emits_alter_column_type() {
4902        // A genuine type change (text -> int8) on an existing column must be
4903        // migrated, not silently left stale.
4904        let mut td = simple_type("default", "Person", "Person");
4905        td.properties.push(prop("rating", "int8", true));
4906        let schema = SchemaDescriptor {
4907            types: vec![td],
4908            scalars: vec![],
4909            enums: vec![],
4910            named_tuples: vec![],
4911            globals: vec![],
4912            functions: vec![],
4913            aliases: vec![],
4914            channels: vec![],
4915            ..Default::default()
4916        };
4917        let state = DbState {
4918            schemas: vec!["default".into()],
4919            tables: vec![DbTable {
4920                schema: "default".into(),
4921                name: "Person".into(),
4922                columns: vec![
4923                    DbColumn {
4924                        name: "id".into(),
4925                        pg_type: "uuid".into(),
4926                        nullable: false,
4927                        is_generated: false,
4928                        column_default: Some("uuidv7()".into()),
4929                    },
4930                    DbColumn {
4931                        name: "name".into(),
4932                        pg_type: "text".into(),
4933                        nullable: true,
4934                        is_generated: false,
4935                        column_default: None,
4936                    },
4937                    DbColumn {
4938                        name: "rating".into(),
4939                        pg_type: "text".into(),
4940                        nullable: true,
4941                        is_generated: false,
4942                        column_default: None,
4943                    },
4944                ],
4945                foreign_keys: vec![],
4946                indexes: vec![],
4947                checks: vec![],
4948                triggers: vec![],
4949            }],
4950            enums: vec![],
4951            domains: vec![],
4952            ..DbState::default()
4953        };
4954        let ops = diff_schema(&schema, &state).unwrap();
4955        let joined = ops.join("\n");
4956        assert!(
4957            joined.contains(
4958                "ALTER TABLE \"public\".\"Person\" ALTER COLUMN \"rating\" TYPE int8 USING \"rating\"::int8;"
4959            ),
4960            "got:\n{joined}"
4961        );
4962    }
4963
4964    #[test]
4965    fn test_a_column_moving_between_enums_converts_through_text() {
4966        // PostgreSQL has no enum-to-enum cast, so the direct one the diff used
4967        // to emit aborted the migration outright: `cannot cast type "Method"[]
4968        // to "HttpMethod"[]`. Renaming an enum is the ordinary way to reach
4969        // this.
4970        let mut td = simple_type("default", "Person", "Person");
4971        td.properties.push(prop("verb", "\"public\".\"HttpMethod\"", true));
4972        let schema = SchemaDescriptor {
4973            types: vec![td],
4974            enums: vec![crate::schema::EnumDescriptor {
4975                name: "HttpMethod".into(),
4976                module: "default".into(),
4977                members: vec!["GET".into(), "POST".into()],
4978            }],
4979            ..Default::default()
4980        };
4981        let state = DbState {
4982            schemas: vec!["default".into()],
4983            tables: vec![DbTable {
4984                schema: "default".into(),
4985                name: "Person".into(),
4986                columns: vec![
4987                    DbColumn {
4988                        name: "id".into(),
4989                        pg_type: "uuid".into(),
4990                        nullable: false,
4991                        is_generated: false,
4992                        column_default: Some("uuidv7()".into()),
4993                    },
4994                    DbColumn {
4995                        name: "verb".into(),
4996                        pg_type: "\"public\".\"Method\"".into(),
4997                        nullable: true,
4998                        is_generated: false,
4999                        column_default: None,
5000                    },
5001                ],
5002                foreign_keys: vec![],
5003                indexes: vec![],
5004                checks: vec![],
5005                triggers: vec![],
5006            }],
5007            enums: vec![],
5008            domains: vec![],
5009            ..DbState::default()
5010        };
5011        let joined = diff_schema(&schema, &state).unwrap().join("\n");
5012        assert!(
5013            joined.contains(r#"USING "verb"::text::"public"."HttpMethod";"#),
5014            "the conversion should go through text:\n{joined}"
5015        );
5016    }
5017
5018    #[test]
5019    fn test_a_column_default_is_dropped_before_its_type_changes() {
5020        // Postgres checks the existing DEFAULT against the new type before any
5021        // later `SET DEFAULT` can replace it: "default for column ... cannot
5022        // be cast automatically". The drop has to come first.
5023        let mut td = simple_type("default", "Person", "Person");
5024        let mut rating = prop("rating", "int8", true);
5025        rating.default_sql = Some("'1'".into());
5026        td.properties.push(rating);
5027        let schema = SchemaDescriptor {
5028            types: vec![td],
5029            ..Default::default()
5030        };
5031        let state = DbState {
5032            schemas: vec!["default".into()],
5033            tables: vec![DbTable {
5034                schema: "default".into(),
5035                name: "Person".into(),
5036                columns: vec![
5037                    DbColumn {
5038                        name: "id".into(),
5039                        pg_type: "uuid".into(),
5040                        nullable: false,
5041                        is_generated: false,
5042                        column_default: Some("uuidv7()".into()),
5043                    },
5044                    DbColumn {
5045                        name: "rating".into(),
5046                        pg_type: "text".into(),
5047                        nullable: true,
5048                        is_generated: false,
5049                        column_default: Some("'x'::text".into()),
5050                    },
5051                ],
5052                foreign_keys: vec![],
5053                indexes: vec![],
5054                checks: vec![],
5055                triggers: vec![],
5056            }],
5057            enums: vec![],
5058            domains: vec![],
5059            ..DbState::default()
5060        };
5061        let joined = diff_schema(&schema, &state).unwrap().join("\n");
5062        let Some(drop) = joined.find(r#"ALTER COLUMN "rating" DROP DEFAULT;"#) else {
5063            panic!("the default should be dropped:\n{joined}");
5064        };
5065        let Some(retype) = joined.find(r#"ALTER COLUMN "rating" TYPE int8"#) else {
5066            panic!("the type should change:\n{joined}");
5067        };
5068        assert!(drop < retype, "the drop must come first:\n{joined}");
5069    }
5070
5071    #[test]
5072    fn test_property_type_change_surfaces_a_required_cast_expression_step() {
5073        let mut td = simple_type("default", "Person", "Person");
5074        td.properties.push(prop("rating", "int8", true));
5075        let schema = SchemaDescriptor {
5076            types: vec![td],
5077            scalars: vec![],
5078            enums: vec![],
5079            named_tuples: vec![],
5080            globals: vec![],
5081            functions: vec![],
5082            aliases: vec![],
5083            channels: vec![],
5084            ..Default::default()
5085        };
5086        let state = DbState {
5087            schemas: vec!["default".into()],
5088            tables: vec![DbTable {
5089                schema: "default".into(),
5090                name: "Person".into(),
5091                columns: vec![
5092                    DbColumn {
5093                        name: "id".into(),
5094                        pg_type: "uuid".into(),
5095                        nullable: false,
5096                        is_generated: false,
5097                        column_default: Some("uuidv7()".into()),
5098                    },
5099                    DbColumn {
5100                        name: "name".into(),
5101                        pg_type: "text".into(),
5102                        nullable: true,
5103                        is_generated: false,
5104                        column_default: None,
5105                    },
5106                    DbColumn {
5107                        name: "rating".into(),
5108                        pg_type: "text".into(),
5109                        nullable: true,
5110                        is_generated: false,
5111                        column_default: None,
5112                    },
5113                ],
5114                foreign_keys: vec![],
5115                indexes: vec![],
5116                checks: vec![],
5117                triggers: vec![],
5118            }],
5119            enums: vec![],
5120            domains: vec![],
5121            ..DbState::default()
5122        };
5123
5124        let steps = diff_schema_steps(&schema, &state, &HashMap::new()).unwrap();
5125        let step = steps
5126            .iter()
5127            .find(|s| matches!(&s.op_key, OpKey::Table(m, t) if m == "default" && t == "Person"))
5128            .expect("expected an alter step for Person");
5129
5130        assert_eq!(step.required_input.len(), 1, "got: {:?}", step.required_input);
5131        let input = &step.required_input[0];
5132        assert_eq!(input.placeholder, "cast_expr__rating");
5133        assert_eq!(input.default_expr, "\"rating\"::int8");
5134        assert_eq!(input.type_name, "default::Person");
5135
5136        let placeholder_token = format!("\\({})", input.placeholder);
5137        assert!(
5138            step.ddl.iter().any(|op| op.sql.contains(&placeholder_token)),
5139            "expected the placeholder token in the step's DDL, got: {:?}",
5140            step.ddl.iter().map(|op| &op.sql).collect::<Vec<_>>()
5141        );
5142    }
5143
5144    #[test]
5145    fn test_equivalent_base_type_spelling_is_not_a_diff() {
5146        // format_type() reports Postgres's own canonical alias spelling
5147        // (int8 -> bigint, timestamptz -> timestamp with time zone, ...) —
5148        // comparing against that verbatim would treat every existing
5149        // column as "changed" on every diff. Confirms the alias table
5150        // avoids that false positive.
5151        let mut td = simple_type("default", "Person", "Person");
5152        td.properties.push(prop("age", "int8", true));
5153        let schema = SchemaDescriptor {
5154            types: vec![td],
5155            scalars: vec![],
5156            enums: vec![],
5157            named_tuples: vec![],
5158            globals: vec![],
5159            functions: vec![],
5160            aliases: vec![],
5161            channels: vec![],
5162            ..Default::default()
5163        };
5164        let state = DbState {
5165            schemas: vec!["default".into()],
5166            tables: vec![DbTable {
5167                schema: "default".into(),
5168                name: "Person".into(),
5169                columns: vec![
5170                    DbColumn {
5171                        name: "id".into(),
5172                        pg_type: "uuid".into(),
5173                        nullable: false,
5174                        is_generated: false,
5175                        column_default: Some("uuidv7()".into()),
5176                    },
5177                    DbColumn {
5178                        name: "name".into(),
5179                        pg_type: "text".into(),
5180                        nullable: true,
5181                        is_generated: false,
5182                        column_default: None,
5183                    },
5184                    DbColumn {
5185                        name: "age".into(),
5186                        pg_type: "bigint".into(),
5187                        nullable: true,
5188                        is_generated: false,
5189                        column_default: None,
5190                    },
5191                ],
5192                foreign_keys: vec![],
5193                indexes: vec![],
5194                checks: vec![],
5195                triggers: vec!["pylon_cache_invalidate".into()],
5196            }],
5197            enums: vec![],
5198            domains: vec![],
5199            ..DbState::default()
5200        };
5201        let ops = diff_schema(&schema, &state).unwrap();
5202        assert!(
5203            ops.iter().all(|op| !op.contains("ALTER COLUMN")),
5204            "expected no ALTER COLUMN ops, got: {:?}",
5205            ops
5206        );
5207    }
5208
5209    #[test]
5210    fn test_check_the_schema_no_longer_declares_is_dropped() {
5211        // A check's name hashes its predicate, so editing one leaves the old
5212        // constraint in place beside the new — and both would have to hold.
5213        let mut person = simple_type("default", "Person", "Person");
5214        person.properties = vec![prop("id", "uuid", false)];
5215        let schema = SchemaDescriptor {
5216            types: vec![person],
5217            ..SchemaDescriptor::default()
5218        };
5219        let state = DbState {
5220            tables: vec![DbTable {
5221                schema: "default".into(),
5222                name: "Person".into(),
5223                columns: vec![DbColumn {
5224                    name: "id".into(),
5225                    pg_type: "uuid".into(),
5226                    nullable: false,
5227                    is_generated: false,
5228                    column_default: None,
5229                }],
5230                foreign_keys: vec![],
5231                indexes: vec![],
5232                checks: vec![
5233                    DbCheck {
5234                        constraint_name: "Person_name_deadbeef_check".into(),
5235                    },
5236                    DbCheck {
5237                        constraint_name: "a_hand_written_check".into(),
5238                    },
5239                ],
5240                triggers: vec![],
5241            }],
5242            ..DbState::default()
5243        };
5244        let ops = diff_schema_ops(&schema, &state).unwrap();
5245        let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5246        assert!(
5247            joined.contains("DROP CONSTRAINT IF EXISTS \"Person_name_deadbeef_check\""),
5248            "got:\n{joined}"
5249        );
5250        assert!(
5251            !joined.contains("a_hand_written_check"),
5252            "a constraint Pylon did not name is left alone:\n{joined}"
5253        );
5254    }
5255
5256    #[test]
5257    fn test_changed_scalar_domain_checks_are_reconciled() {
5258        use crate::schema::ScalarDescriptor;
5259        let schema = SchemaDescriptor {
5260            scalars: vec![ScalarDescriptor {
5261                name: "EmailStr".into(),
5262                module: "default".into(),
5263                base: "Str".into(),
5264                pg_type: "text".into(),
5265                check_constraints: vec!["char_length(VALUE) <= 320".into()],
5266                is_sequence: false,
5267            }],
5268            ..SchemaDescriptor::default()
5269        };
5270        let state = DbState {
5271            domains: vec![DbDomain {
5272                schema: "default".into(),
5273                name: "EmailStr".into(),
5274                checks: vec!["EmailStr_stale000_check".into()],
5275            }],
5276            ..DbState::default()
5277        };
5278        let ops = diff_schema_ops(&schema, &state).unwrap();
5279        let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5280        assert!(joined.contains("DROP CONSTRAINT"), "the old set is cleared:\n{joined}");
5281        assert!(
5282            joined.contains("ADD CONSTRAINT") && joined.contains("char_length(VALUE) <= 320"),
5283            "the new set is added:\n{joined}"
5284        );
5285    }
5286
5287    #[test]
5288    fn test_demoting_an_interface_to_a_mixin_drops_its_view() {
5289        // A mixin is flattened onto its implementors and has no relation of
5290        // its own, so the view the interface had must go with it.
5291        let mut individual = simple_type("default", "Individual", "Individual");
5292        individual.properties = vec![prop("id", "uuid", false)];
5293        let schema = SchemaDescriptor {
5294            types: vec![individual],
5295            ..SchemaDescriptor::default()
5296        };
5297        let state = DbState {
5298            tables: vec![],
5299            views: vec![DbView {
5300                schema: "public".into(),
5301                name: "Account".into(),
5302                body_hash: "whatever".into(),
5303            }],
5304            ..DbState::default()
5305        };
5306        let ops = diff_schema_ops(&schema, &state).unwrap();
5307        let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5308        assert!(
5309            joined.contains("DROP VIEW IF EXISTS \"public\".\"Account\""),
5310            "got:\n{joined}"
5311        );
5312    }
5313
5314    #[test]
5315    fn test_registered_scalar_domain_adoption_drops_and_recreates_dependent_interface_view() {
5316        // A property switching to a registered custom scalar's own DOMAIN
5317        // (PropertyDescriptor.column_type) on a table an interface view
5318        // selects from must drop that view before the ALTER (Postgres
5319        // refuses to retype a column a view depends on) and recreate it
5320        // afterward — not silently fail, and not leave the view missing.
5321        use crate::schema::ScalarDescriptor;
5322
5323        let mut account = simple_type("default", "Account", "Account");
5324        account.abstract_ = true;
5325        account.materialized = true;
5326        account.properties = vec![prop("id", "uuid", false), prop("email", "text", false)];
5327        account.properties[1].column_type = Some("\"public\".\"Email\"".into());
5328
5329        let mut individual = simple_type("default", "Individual", "Individual");
5330        individual.interfaces = vec!["default::Account".into()];
5331        individual.properties = vec![prop("id", "uuid", false), prop("email", "text", false)];
5332        individual.properties[1].column_type = Some("\"public\".\"Email\"".into());
5333
5334        let schema = SchemaDescriptor {
5335            types: vec![account, individual],
5336            scalars: vec![ScalarDescriptor {
5337                name: "Email".into(),
5338                module: "default".into(),
5339                base: "Str".into(),
5340                pg_type: "text".into(),
5341                check_constraints: vec!["value ~ '@'".into()],
5342                is_sequence: false,
5343            }],
5344            enums: vec![],
5345            named_tuples: vec![],
5346            globals: vec![],
5347            functions: vec![],
5348            aliases: vec![],
5349            channels: vec![],
5350            ..Default::default()
5351        };
5352
5353        // The view's own SELECT text never mentions column types, so its
5354        // hash is unaffected by this migration — matching that hash in
5355        // `current` confirms Phase 10 doesn't ALSO try to (redundantly,
5356        // and invalidly, since it'd already exist) recreate it.
5357        let view_ddl = crate::export::interface_view_ddl_with_names(&schema)
5358            .into_iter()
5359            .find(|(_, n, _)| n == "Account")
5360            .unwrap()
5361            .2;
5362
5363        let state = DbState {
5364            schemas: vec!["default".into()],
5365            tables: vec![DbTable {
5366                schema: "default".into(),
5367                name: "Individual".into(),
5368                columns: vec![
5369                    DbColumn {
5370                        name: "id".into(),
5371                        pg_type: "uuid".into(),
5372                        nullable: false,
5373                        is_generated: false,
5374                        column_default: Some("uuidv7()".into()),
5375                    },
5376                    DbColumn {
5377                        name: "email".into(),
5378                        pg_type: "text".into(),
5379                        nullable: false,
5380                        is_generated: false,
5381                        column_default: None,
5382                    },
5383                ],
5384                foreign_keys: vec![],
5385                indexes: vec![],
5386                checks: vec![],
5387                triggers: vec!["pylon_cache_invalidate".into()],
5388            }],
5389            views: vec![DbView {
5390                schema: "default".into(),
5391                name: "Account".into(),
5392                body_hash: ddl_hash(&view_ddl),
5393            }],
5394            enums: vec![],
5395            domains: vec![],
5396            ..DbState::default()
5397        };
5398
5399        // Migration mode (`diff_schema_ops`, what `pylon migration create`
5400        // uses) — unlike watch mode, this respects the interface view's
5401        // unchanged DDL hash and doesn't redundantly re-emit it.
5402        let ops = diff_schema_ops(&schema, &state).unwrap();
5403        let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5404        let drop_pos = joined
5405            .find("DROP VIEW IF EXISTS \"public\".\"Account\"")
5406            .unwrap_or_else(|| panic!("missing DROP VIEW; got:\n{joined}"));
5407        let alter_pos = joined
5408            .find("ALTER TABLE \"public\".\"Individual\" ALTER COLUMN \"email\" TYPE \"public\".\"Email\"")
5409            .unwrap_or_else(|| panic!("missing ALTER COLUMN TYPE; got:\n{joined}"));
5410        let create_pos = joined
5411            .rfind("CREATE VIEW \"public\".\"Account\"")
5412            .unwrap_or_else(|| panic!("missing CREATE VIEW; got:\n{joined}"));
5413        assert!(drop_pos < alter_pos, "DROP VIEW must precede the ALTER; got:\n{joined}");
5414        assert!(
5415            alter_pos < create_pos,
5416            "CREATE VIEW must follow the ALTER; got:\n{joined}"
5417        );
5418        assert_eq!(
5419            joined.matches("CREATE VIEW \"public\".\"Account\"").count(),
5420            1,
5421            "view must be recreated exactly once, not duplicated by Phase 10; got:\n{joined}"
5422        );
5423    }
5424
5425    fn exclusive_email_account_schema(implementor_names: &[&str]) -> SchemaDescriptor {
5426        let mut account = simple_type("default", "Account", "Account");
5427        account.abstract_ = true;
5428        account.materialized = true;
5429        account.properties = vec![prop("id", "uuid", false), prop("email", "text", false)];
5430        account.properties[1].is_exclusive = true;
5431
5432        let mut types = vec![account];
5433        for name in implementor_names {
5434            let mut t = simple_type("default", name, name);
5435            t.interfaces = vec!["default::Account".into()];
5436            t.properties = vec![prop("id", "uuid", false), prop("email", "text", false)];
5437            t.properties[1].is_exclusive = true;
5438            types.push(t);
5439        }
5440        SchemaDescriptor {
5441            types,
5442            scalars: vec![],
5443            enums: vec![],
5444            named_tuples: vec![],
5445            globals: vec![],
5446            functions: vec![],
5447            aliases: vec![],
5448            channels: vec![],
5449            ..Default::default()
5450        }
5451    }
5452
5453    #[test]
5454    fn test_new_implementor_added_to_existing_interface_gets_exclusive_triggers_retroactively() {
5455        // `Individual` already exists (with its own triggers already
5456        // applied by a prior migration); `Organization` is a brand new
5457        // implementor of the same interface — it must get the cross-table
5458        // exclusive trigger from the moment its table is created, and
5459        // `Individual`'s already-present triggers must not be re-emitted.
5460        let schema = exclusive_email_account_schema(&["Individual", "Organization"]);
5461        let state = DbState {
5462            schemas: vec!["default".into()],
5463            tables: vec![DbTable {
5464                schema: "default".into(),
5465                name: "Individual".into(),
5466                columns: vec![
5467                    DbColumn {
5468                        name: "id".into(),
5469                        pg_type: "uuid".into(),
5470                        nullable: false,
5471                        is_generated: false,
5472                        column_default: Some("uuidv7()".into()),
5473                    },
5474                    DbColumn {
5475                        name: "email".into(),
5476                        pg_type: "text".into(),
5477                        nullable: false,
5478                        is_generated: false,
5479                        column_default: None,
5480                    },
5481                ],
5482                foreign_keys: vec![],
5483                indexes: vec![],
5484                checks: vec![],
5485                triggers: vec![
5486                    "pylon_cache_invalidate".into(),
5487                    "_excl_Account_email_ins".into(),
5488                    "_excl_Account_email_upd".into(),
5489                ],
5490            }],
5491            enums: vec![],
5492            domains: vec![],
5493            ..DbState::default()
5494        };
5495
5496        let ops = diff_schema_ops(&schema, &state).unwrap();
5497        let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5498        assert!(
5499            joined.contains(
5500                "CREATE CONSTRAINT TRIGGER \"_excl_Account_email_ins\"\nAFTER INSERT ON \"public\".\"Organization\""
5501            ),
5502            "the new implementor must get the exclusive trigger; got:\n{joined}"
5503        );
5504        assert!(
5505            !joined.contains("ON \"public\".\"Individual\""),
5506            "the already-migrated implementor's existing triggers must not be re-emitted; got:\n{joined}"
5507        );
5508    }
5509
5510    #[test]
5511    fn test_the_shared_exclusive_guard_function_precedes_every_implementors_trigger() {
5512        // One guard function serves every implementor, so it is emitted with
5513        // a single one of them -- and that has to be whichever implementor's
5514        // step renders first. `Individual` is declared first but links to
5515        // `Organization`, so topological order creates `Organization`'s table
5516        // (and its trigger) ahead of it.
5517        let mut schema = exclusive_email_account_schema(&["Individual", "Organization"]);
5518        let individual = schema
5519            .types
5520            .iter_mut()
5521            .find(|t| t.name == "Individual")
5522            .expect("Individual is in the schema");
5523        individual.links.push(LinkDescriptor {
5524            name: "employer".into(),
5525            target: "default::Organization".into(),
5526            nullable: false,
5527            through: None,
5528            description: None,
5529            default_pyql: None,
5530            is_exclusive: false,
5531            is_readonly: false,
5532            rewrites: vec![],
5533            on_delete: vec![],
5534        });
5535
5536        let ops = diff_schema_ops(&schema, &DbState::default()).unwrap();
5537        let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5538        let function_pos = joined
5539            .find("CREATE OR REPLACE FUNCTION \"public\".\"_excl_Account_email\"")
5540            .unwrap_or_else(|| panic!("missing the guard function; got:\n{joined}"));
5541        let first_trigger_pos = joined
5542            .find("CREATE CONSTRAINT TRIGGER \"_excl_Account_email_ins\"")
5543            .unwrap_or_else(|| panic!("missing the exclusive triggers; got:\n{joined}"));
5544        assert!(
5545            function_pos < first_trigger_pos,
5546            "the guard function must be defined before the first trigger naming it; got:\n{joined}"
5547        );
5548        assert_eq!(
5549            joined
5550                .matches("CREATE OR REPLACE FUNCTION \"public\".\"_excl_Account_email\"")
5551                .count(),
5552            1,
5553            "the guard function must be emitted exactly once; got:\n{joined}"
5554        );
5555    }
5556
5557    #[test]
5558    fn test_removing_exclusivity_drops_the_cross_table_triggers() {
5559        // The target schema no longer marks `email` exclusive at all (the
5560        // user removed `Exclusive` from the interface) — both the INSERT
5561        // and UPDATE constraint triggers on every implementor must be
5562        // dropped, matching any other trigger's removal.
5563        let mut schema = exclusive_email_account_schema(&["Individual"]);
5564        for t in &mut schema.types {
5565            for p in &mut t.properties {
5566                if p.name == "email" {
5567                    p.is_exclusive = false;
5568                }
5569            }
5570        }
5571        let state = DbState {
5572            schemas: vec!["default".into()],
5573            tables: vec![DbTable {
5574                schema: "default".into(),
5575                name: "Individual".into(),
5576                columns: vec![
5577                    DbColumn {
5578                        name: "id".into(),
5579                        pg_type: "uuid".into(),
5580                        nullable: false,
5581                        is_generated: false,
5582                        column_default: Some("uuidv7()".into()),
5583                    },
5584                    DbColumn {
5585                        name: "email".into(),
5586                        pg_type: "text".into(),
5587                        nullable: false,
5588                        is_generated: false,
5589                        column_default: None,
5590                    },
5591                ],
5592                foreign_keys: vec![],
5593                indexes: vec![],
5594                checks: vec![],
5595                triggers: vec![
5596                    "pylon_cache_invalidate".into(),
5597                    "_excl_Account_email_ins".into(),
5598                    "_excl_Account_email_upd".into(),
5599                ],
5600            }],
5601            enums: vec![],
5602            domains: vec![],
5603            ..DbState::default()
5604        };
5605
5606        let ops = diff_schema_ops(&schema, &state).unwrap();
5607        let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5608        assert!(
5609            joined.contains("DROP TRIGGER IF EXISTS \"_excl_Account_email_ins\" ON \"public\".\"Individual\""),
5610            "got:\n{joined}"
5611        );
5612        assert!(
5613            joined.contains("DROP TRIGGER IF EXISTS \"_excl_Account_email_upd\" ON \"public\".\"Individual\""),
5614            "got:\n{joined}"
5615        );
5616        assert!(
5617            !joined.contains("DROP TRIGGER IF EXISTS \"pylon_cache_invalidate\""),
5618            "unrelated triggers must not be touched; got:\n{joined}"
5619        );
5620    }
5621
5622    #[test]
5623    fn test_new_enum() {
5624        let schema = SchemaDescriptor {
5625            types: vec![],
5626            scalars: vec![],
5627            enums: vec![EnumDescriptor {
5628                name: "Status".into(),
5629                module: "default".into(),
5630                members: vec!["Active".into(), "Inactive".into()],
5631            }],
5632            named_tuples: vec![],
5633            globals: vec![],
5634            functions: vec![],
5635            aliases: vec![],
5636            channels: vec![],
5637            ..Default::default()
5638        };
5639        let ops = diff_schema(&schema, &empty_state()).unwrap();
5640        let joined = ops.join("\n");
5641        assert!(
5642            joined.contains("CREATE TYPE \"public\".\"Status\" AS ENUM"),
5643            "got:\n{joined}"
5644        );
5645    }
5646
5647    #[test]
5648    fn test_drop_table() {
5649        let schema = SchemaDescriptor {
5650            types: vec![],
5651            scalars: vec![],
5652            enums: vec![],
5653            named_tuples: vec![],
5654            globals: vec![],
5655            functions: vec![],
5656            aliases: vec![],
5657            channels: vec![],
5658            ..Default::default()
5659        };
5660        let state = DbState {
5661            schemas: vec!["default".into()],
5662            tables: vec![DbTable {
5663                schema: "default".into(),
5664                name: "OldType".into(),
5665                columns: vec![],
5666                foreign_keys: vec![],
5667                indexes: vec![],
5668                checks: vec![],
5669                triggers: vec![],
5670            }],
5671            enums: vec![],
5672            domains: vec![],
5673            ..DbState::default()
5674        };
5675        let ops = diff_schema(&schema, &state).unwrap();
5676        let joined = ops.join("\n");
5677        assert!(
5678            joined.contains("DROP TABLE IF EXISTS \"public\".\"OldType\" CASCADE"),
5679            "got:\n{joined}"
5680        );
5681    }
5682
5683    #[test]
5684    fn test_index_on_existing_table_is_concurrently() {
5685        use crate::schema::VectorIndexDescriptor;
5686        let mut td = simple_type("default", "Post", "Post");
5687        td.vector_indexes.push(VectorIndexDescriptor {
5688            index_name: None,
5689            pointers: vec!["name".into()],
5690            model: "test".into(),
5691            metric: "cosine".into(),
5692            dimensions: 1536,
5693        });
5694        let schema = SchemaDescriptor {
5695            types: vec![td],
5696            scalars: vec![],
5697            enums: vec![],
5698            named_tuples: vec![],
5699            globals: vec![],
5700            functions: vec![],
5701            aliases: vec![],
5702            channels: vec![],
5703            ..Default::default()
5704        };
5705        // The table already exists in the DB (pre-existing).
5706        let state = DbState {
5707            schemas: vec!["default".into()],
5708            tables: vec![DbTable {
5709                schema: "default".into(),
5710                name: "Post".into(),
5711                columns: vec![
5712                    DbColumn {
5713                        name: "id".into(),
5714                        pg_type: "uuid".into(),
5715                        nullable: false,
5716                        is_generated: false,
5717                        column_default: Some("uuidv7()".into()),
5718                    },
5719                    DbColumn {
5720                        name: "name".into(),
5721                        pg_type: "text".into(),
5722                        nullable: true,
5723                        is_generated: false,
5724                        column_default: None,
5725                    },
5726                ],
5727                foreign_keys: vec![],
5728                indexes: vec![],
5729                checks: vec![],
5730                triggers: vec![],
5731            }],
5732            enums: vec![],
5733            domains: vec![],
5734            ..DbState::default()
5735        };
5736        let ops = diff_schema_ops(&schema, &state).unwrap();
5737        let idx_op = ops.iter().find(|op| op.sql.contains("hnsw")).unwrap();
5738        assert!(
5739            idx_op.non_transactional,
5740            "index on pre-existing table should be non-transactional"
5741        );
5742        assert!(
5743            idx_op.sql.contains("CONCURRENTLY"),
5744            "should use CONCURRENTLY: {}",
5745            idx_op.sql
5746        );
5747    }
5748
5749    #[test]
5750    fn test_required_extensions_empty_without_vector_indexes() {
5751        let schema = SchemaDescriptor {
5752            types: vec![simple_type("default", "Post", "Post")],
5753            scalars: vec![],
5754            enums: vec![],
5755            named_tuples: vec![],
5756            globals: vec![],
5757            functions: vec![],
5758            aliases: vec![],
5759            channels: vec![],
5760            ..Default::default()
5761        };
5762        assert!(required_extensions(&schema).is_empty());
5763    }
5764
5765    #[test]
5766    fn test_missing_extension_ddl_when_vector_index_present_and_not_yet_installed() {
5767        use crate::schema::VectorIndexDescriptor;
5768        let mut td = simple_type("default", "Post", "Post");
5769        td.vector_indexes.push(VectorIndexDescriptor {
5770            index_name: None,
5771            pointers: vec!["name".into()],
5772            model: "test".into(),
5773            metric: "cosine".into(),
5774            dimensions: 1536,
5775        });
5776        let schema = SchemaDescriptor {
5777            types: vec![td],
5778            scalars: vec![],
5779            enums: vec![],
5780            named_tuples: vec![],
5781            globals: vec![],
5782            functions: vec![],
5783            aliases: vec![],
5784            channels: vec![],
5785            ..Default::default()
5786        };
5787        assert_eq!(required_extensions(&schema), vec!["vector"]);
5788
5789        let ddl = missing_extension_ddl(&schema, &DbState::default());
5790        assert_eq!(ddl, vec!["CREATE EXTENSION IF NOT EXISTS \"vector\";".to_string()]);
5791
5792        let already_installed = DbState {
5793            extensions: vec!["vector".into()],
5794            ..DbState::default()
5795        };
5796        assert!(missing_extension_ddl(&schema, &already_installed).is_empty());
5797    }
5798
5799    #[test]
5800    fn test_index_on_new_table_is_transactional() {
5801        use crate::schema::VectorIndexDescriptor;
5802        let mut td = simple_type("default", "Post", "Post");
5803        td.vector_indexes.push(VectorIndexDescriptor {
5804            index_name: None,
5805            pointers: vec!["name".into()],
5806            model: "test".into(),
5807            metric: "cosine".into(),
5808            dimensions: 1536,
5809        });
5810        let schema = SchemaDescriptor {
5811            types: vec![td],
5812            scalars: vec![],
5813            enums: vec![],
5814            named_tuples: vec![],
5815            globals: vec![],
5816            functions: vec![],
5817            aliases: vec![],
5818            channels: vec![],
5819            ..Default::default()
5820        };
5821        // Table does NOT exist in the DB → it's new.
5822        let ops = diff_schema_ops(&schema, &empty_state()).unwrap();
5823        let idx_op = ops.iter().find(|op| op.sql.contains("hnsw")).unwrap();
5824        assert!(!idx_op.non_transactional, "index on new table should be transactional");
5825        assert!(
5826            !idx_op.sql.contains("CONCURRENTLY"),
5827            "should NOT use CONCURRENTLY: {}",
5828            idx_op.sql
5829        );
5830    }
5831
5832    fn sequence_scalar(module: &str, name: &str) -> crate::schema::ScalarDescriptor {
5833        crate::schema::ScalarDescriptor {
5834            name: name.into(),
5835            module: module.into(),
5836            base: "Sequence".into(),
5837            pg_type: "int8".into(),
5838            check_constraints: vec![],
5839            is_sequence: true,
5840        }
5841    }
5842
5843    #[test]
5844    fn test_new_sequence_creates_sequence_and_domain() {
5845        let schema = SchemaDescriptor {
5846            types: vec![],
5847            enums: vec![],
5848            named_tuples: vec![],
5849            globals: vec![],
5850            functions: vec![],
5851            aliases: vec![],
5852            channels: vec![],
5853            scalars: vec![sequence_scalar("default", "OrderNumber")],
5854            ..Default::default()
5855        };
5856        let ops = diff_schema(&schema, &empty_state()).unwrap();
5857        let joined = ops.join("\n");
5858        assert!(
5859            joined.contains("CREATE SEQUENCE IF NOT EXISTS \"public\".\"OrderNumber_seq\""),
5860            "got:\n{joined}"
5861        );
5862        assert!(
5863            joined.contains("CREATE DOMAIN \"public\".\"OrderNumber\" AS int8"),
5864            "got:\n{joined}"
5865        );
5866        // Sequence must precede domain
5867        let seq_pos = joined.find("CREATE SEQUENCE").unwrap();
5868        let dom_pos = joined.find("CREATE DOMAIN").unwrap();
5869        assert!(seq_pos < dom_pos, "sequence must be created before domain");
5870    }
5871
5872    #[test]
5873    fn test_no_ops_sequence_already_exists() {
5874        let schema = SchemaDescriptor {
5875            types: vec![],
5876            enums: vec![],
5877            named_tuples: vec![],
5878            globals: vec![],
5879            functions: vec![],
5880            aliases: vec![],
5881            channels: vec![],
5882            scalars: vec![sequence_scalar("default", "OrderNumber")],
5883            ..Default::default()
5884        };
5885        let state = DbState {
5886            schemas: vec!["default".into()],
5887            domains: vec![DbDomain {
5888                schema: "default".into(),
5889                name: "OrderNumber".into(),
5890                checks: vec![],
5891            }],
5892            sequences: vec![DbSequence {
5893                schema: "default".into(),
5894                name: "OrderNumber_seq".into(),
5895            }],
5896            ..DbState::default()
5897        };
5898        let ops = diff_schema(&schema, &state).unwrap();
5899        assert!(
5900            ops.is_empty(),
5901            "expected no ops when sequence and domain exist, got: {:?}",
5902            ops
5903        );
5904    }
5905
5906    #[test]
5907    fn test_drop_removed_sequence() {
5908        let schema = SchemaDescriptor {
5909            types: vec![],
5910            scalars: vec![],
5911            enums: vec![],
5912            named_tuples: vec![],
5913            globals: vec![],
5914            functions: vec![],
5915            aliases: vec![],
5916            channels: vec![],
5917            ..Default::default()
5918        };
5919        let state = DbState {
5920            schemas: vec!["default".into()],
5921            domains: vec![DbDomain {
5922                schema: "default".into(),
5923                name: "OrderNumber".into(),
5924                checks: vec![],
5925            }],
5926            sequences: vec![DbSequence {
5927                schema: "default".into(),
5928                name: "OrderNumber_seq".into(),
5929            }],
5930            ..DbState::default()
5931        };
5932        let ops = diff_schema(&schema, &state).unwrap();
5933        let joined = ops.join("\n");
5934        assert!(
5935            joined.contains("DROP DOMAIN IF EXISTS \"public\".\"OrderNumber\""),
5936            "got:\n{joined}"
5937        );
5938        assert!(
5939            joined.contains("DROP SEQUENCE IF EXISTS \"public\".\"OrderNumber_seq\""),
5940            "got:\n{joined}"
5941        );
5942    }
5943
5944    // ── MigrationStep grouping ───────────────────────────────────────────────
5945
5946    #[test]
5947    fn test_diff_schema_steps_groups_multiple_column_changes_into_one_alter_step() {
5948        let mut person = simple_type("default", "Person", "Person");
5949        person.properties.push(prop("nickname", "text", true));
5950        person.properties.push(prop("age", "int8", true));
5951        let schema = SchemaDescriptor {
5952            types: vec![person],
5953            scalars: vec![],
5954            enums: vec![],
5955            named_tuples: vec![],
5956            globals: vec![],
5957            functions: vec![],
5958            aliases: vec![],
5959            channels: vec![],
5960            ..Default::default()
5961        };
5962        let state = DbState {
5963            schemas: vec!["default".into()],
5964            tables: vec![DbTable {
5965                schema: "default".into(),
5966                name: "Person".into(),
5967                columns: vec![
5968                    DbColumn {
5969                        name: "id".into(),
5970                        pg_type: "uuid".into(),
5971                        nullable: false,
5972                        is_generated: false,
5973                        column_default: Some("uuidv7()".into()),
5974                    },
5975                    DbColumn {
5976                        name: "name".into(),
5977                        pg_type: "text".into(),
5978                        nullable: true,
5979                        is_generated: false,
5980                        column_default: None,
5981                    },
5982                ],
5983                foreign_keys: vec![],
5984                indexes: vec![],
5985                checks: vec![],
5986                triggers: vec!["pylon_cache_invalidate".into()],
5987            }],
5988            enums: vec![],
5989            domains: vec![],
5990            ..DbState::default()
5991        };
5992
5993        let steps = diff_schema_steps(&schema, &state, &HashMap::new()).unwrap();
5994        let table_steps: Vec<&MigrationStep> = steps
5995            .iter()
5996            .filter(|s| matches!(&s.op_key, OpKey::Table(m, t) if m == "default" && t == "Person"))
5997            .collect();
5998        assert_eq!(
5999            table_steps.len(),
6000            1,
6001            "two new columns on the same table must produce one step, got: {:?}",
6002            steps.iter().map(|s| &s.prompt).collect::<Vec<_>>()
6003        );
6004        assert_eq!(table_steps[0].verb, Verb::Alter);
6005        assert_eq!(table_steps[0].prompt, "did you alter object type 'default::Person'?");
6006        assert_eq!(
6007            table_steps[0].ddl.len(),
6008            2,
6009            "expected one ADD COLUMN per new property, got: {:?}",
6010            table_steps[0].ddl.iter().map(|d| &d.sql).collect::<Vec<_>>()
6011        );
6012    }
6013
6014    #[test]
6015    fn test_diff_schema_steps_new_table_is_one_create_step_including_its_trigger() {
6016        let schema = SchemaDescriptor {
6017            types: vec![simple_type("catalog", "Product", "Product")],
6018            scalars: vec![],
6019            enums: vec![],
6020            named_tuples: vec![],
6021            globals: vec![],
6022            functions: vec![],
6023            aliases: vec![],
6024            channels: vec![],
6025            ..Default::default()
6026        };
6027        let steps = diff_schema_steps(&schema, &empty_state(), &HashMap::new()).unwrap();
6028        let table_steps: Vec<&MigrationStep> = steps
6029            .iter()
6030            .filter(|s| matches!(&s.op_key, OpKey::Table(m, t) if m == "catalog" && t == "Product"))
6031            .collect();
6032        assert_eq!(
6033            table_steps.len(),
6034            1,
6035            "got steps: {:?}",
6036            steps.iter().map(|s| &s.prompt).collect::<Vec<_>>()
6037        );
6038        assert_eq!(table_steps[0].verb, Verb::Create);
6039        assert_eq!(table_steps[0].prompt, "did you create object type 'catalog::Product'?");
6040
6041        // The cache-invalidation trigger for a brand new table must fold
6042        // into this same create step, not a separate one (Phase 11.5).
6043        let joined: String = table_steps[0]
6044            .ddl
6045            .iter()
6046            .map(|d| d.sql.as_str())
6047            .collect::<Vec<_>>()
6048            .join("\n");
6049        assert!(
6050            joined.contains("CREATE TABLE IF NOT EXISTS \"catalog\".\"Product\""),
6051            "got:\n{joined}"
6052        );
6053        assert!(joined.contains("pylon_cache_invalidate"), "got:\n{joined}");
6054    }
6055
6056    #[test]
6057    fn test_guidance_bans_a_rejected_type_rename_candidate() {
6058        let schema = SchemaDescriptor {
6059            types: vec![simple_type("default", "Customer", "Customer")],
6060            scalars: vec![],
6061            enums: vec![],
6062            named_tuples: vec![],
6063            globals: vec![],
6064            functions: vec![],
6065            aliases: vec![],
6066            channels: vec![],
6067            ..Default::default()
6068        };
6069        let state = DbState {
6070            schemas: vec!["default".into()],
6071            tables: vec![DbTable {
6072                schema: "default".into(),
6073                name: "Person".into(),
6074                columns: vec![
6075                    DbColumn {
6076                        name: "id".into(),
6077                        pg_type: "uuid".into(),
6078                        nullable: false,
6079                        is_generated: false,
6080                        column_default: Some("uuidv7()".into()),
6081                    },
6082                    DbColumn {
6083                        name: "name".into(),
6084                        pg_type: "text".into(),
6085                        nullable: true,
6086                        is_generated: false,
6087                        column_default: None,
6088                    },
6089                ],
6090                foreign_keys: vec![],
6091                indexes: vec![],
6092                checks: vec![],
6093                triggers: vec![],
6094            }],
6095            enums: vec![],
6096            domains: vec![],
6097            ..DbState::default()
6098        };
6099
6100        let candidates = detect_type_renames(&schema, &state, &Guidance::default());
6101        assert_eq!(
6102            candidates.len(),
6103            1,
6104            "expected Person -> Customer to be proposed as a rename"
6105        );
6106
6107        let mut guidance = Guidance::default();
6108        guidance.banned_type_renames.insert((
6109            "default".to_string(),
6110            "Person".to_string(),
6111            "default".to_string(),
6112            "Customer".to_string(),
6113        ));
6114        let candidates = detect_type_renames(&schema, &state, &guidance);
6115        assert!(
6116            candidates.is_empty(),
6117            "a banned rename candidate must not be re-proposed"
6118        );
6119    }
6120}