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};
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
1810        .as_deref()
1811        .unwrap_or_else(|| p.pg_type.strip_prefix("__nt__:").map(|_| "jsonb").unwrap_or(&p.pg_type))
1812}
1813
1814/// Maps a Pylon-internal base `pg_type` spelling to PostgreSQL's own
1815/// canonical `format_type()` display name (`int8` -> `bigint`, etc.), so a
1816/// freshly-introspected `DbColumn.pg_type` can be compared against a
1817/// target schema's type without every alias spelling looking like drift.
1818/// Recurses into array element types (`int8[]` -> `bigint[]`).
1819fn canonical_pg_type(pg_type: &str) -> String {
1820    if let Some(elem) = pg_type.strip_suffix("[]") {
1821        return format!("{}[]", canonical_pg_type(elem));
1822    }
1823    match pg_type {
1824        "int2" => "smallint",
1825        "int4" => "integer",
1826        "int8" => "bigint",
1827        "float4" => "real",
1828        "float8" => "double precision",
1829        "timestamptz" => "timestamp with time zone",
1830        "timestamp" => "timestamp without time zone",
1831        "time" => "time without time zone",
1832        other => other,
1833    }
1834    .to_string()
1835}
1836
1837/// The bare (unqualified, unquoted) identifier at the end of a possibly
1838/// schema-qualified, possibly-quoted PostgreSQL type reference — e.g.
1839/// `"public"."Gender"`, `public."Gender"`, and `"Gender"` all yield
1840/// `Gender`. `format_type()` omits the schema qualifier whenever it's on
1841/// `search_path` (which a live column's introspected type always is, but
1842/// Pylon's own qualified references never bother checking), so comparing
1843/// only this trailing segment is what lets a domain/enum-typed column's
1844/// *type itself* changing be distinguished from a same-domain column that
1845/// merely looks unqualified.
1846fn bare_type_name(pg_type: &str) -> &str {
1847    pg_type.rsplit('.').next().unwrap_or(pg_type).trim_matches('"')
1848}
1849
1850/// Whether a property's own DDL column type (`col_type_str`'s output —
1851/// either a registered scalar's schema-qualified DOMAIN reference or a
1852/// plain base type) actually differs from a live column's introspected
1853/// `format_type()` string, warranting `ALTER COLUMN ... TYPE`.
1854fn pg_type_changed(target: &str, current: &str) -> bool {
1855    if target.starts_with('"') {
1856        bare_type_name(target) != bare_type_name(current)
1857    } else {
1858        canonical_pg_type(target) != canonical_pg_type(current)
1859    }
1860}
1861
1862// ── Core diff implementation ──────────────────────────────────────────────────
1863
1864fn diff_inner(
1865    target: &SchemaDescriptor,
1866    current: &DbState,
1867    for_migration: bool,
1868    fill_index: &HashMap<(String, String), HashSet<String>>,
1869) -> Result<Vec<MigrationStep>, String> {
1870    let mut steps = StepBuilder::new();
1871
1872    let cur_schemas: HashSet<&str> = current.schemas.iter().map(|s| s.as_str()).collect();
1873    let cur_tables: HashMap<(&str, &str), &DbTable> = current
1874        .tables
1875        .iter()
1876        .map(|t| ((t.schema.as_str(), t.name.as_str()), t))
1877        .collect();
1878    let cur_enums: HashMap<(&str, &str), &DbEnum> = current
1879        .enums
1880        .iter()
1881        .map(|e| ((e.schema.as_str(), e.name.as_str()), e))
1882        .collect();
1883    let cur_domains: HashSet<(&str, &str)> = current
1884        .domains
1885        .iter()
1886        .map(|d| (d.schema.as_str(), d.name.as_str()))
1887        .collect();
1888    let cur_sequences: HashSet<(&str, &str)> = current
1889        .sequences
1890        .iter()
1891        .map(|s| (s.schema.as_str(), s.name.as_str()))
1892        .collect();
1893    let cur_views: HashMap<(&str, &str), &str> = current
1894        .views
1895        .iter()
1896        .map(|v| ((v.schema.as_str(), v.name.as_str()), v.body_hash.as_str()))
1897        .collect();
1898    let cur_functions: HashMap<(&str, &str), &str> = current
1899        .functions
1900        .iter()
1901        .map(|f| ((f.schema.as_str(), f.name.as_str()), f.body_hash.as_str()))
1902        .collect();
1903
1904    let type_map: HashMap<String, (&str, &str)> = target
1905        .types
1906        .iter()
1907        .map(|t| {
1908            (
1909                format!("{}::{}", t.module, t.name),
1910                (t.module.as_str(), t.table.as_str()),
1911            )
1912        })
1913        .collect();
1914    let polymorphic = crate::export::polymorphic_types(target);
1915
1916    // Every kind of top-level declaration can live in a module of its own,
1917    // including one with no types/scalars/enums at all (see the matching
1918    // comment on `export::emit_schemas`, which had the same gap — a
1919    // function/global/alias-only module's own CREATE SCHEMA step was never
1920    // generated, so its first CREATE FUNCTION/etc. failed outright).
1921    let mut target_schemas: HashSet<String> = HashSet::new();
1922    for t in &target.types {
1923        target_schemas.insert(t.module.clone());
1924    }
1925    for e in &target.enums {
1926        target_schemas.insert(e.module.clone());
1927    }
1928    for s in &target.scalars {
1929        target_schemas.insert(s.module.clone());
1930    }
1931    for f in &target.functions {
1932        target_schemas.insert(f.module.clone());
1933    }
1934    for g in &target.globals {
1935        target_schemas.insert(g.module.clone());
1936    }
1937    for a in &target.aliases {
1938        target_schemas.insert(a.module.clone());
1939    }
1940
1941    // ── Phase 1: modules ─────────────────────────────────────────────────────
1942    for module in &target_schemas {
1943        if module == "default" {
1944            continue;
1945        } // public always exists
1946        if !cur_schemas.contains(module.as_str()) {
1947            steps.push(
1948                OpKey::Module(module.clone()),
1949                Verb::Create,
1950                verbosename_module(module),
1951                DiffOp {
1952                    sql: format!("CREATE SCHEMA IF NOT EXISTS {};", pg_schema(module)),
1953                    non_transactional: false,
1954                },
1955            );
1956        }
1957    }
1958
1959    // ── Phase 2: enums (described as "scalar type", matching how a custom
1960    // scalar domain is described — see `verbosename_scalar`) ─────────────────
1961    for e in &target.enums {
1962        match cur_enums.get(&(e.module.as_str(), e.name.as_str())) {
1963            None => {
1964                let members: Vec<String> = e
1965                    .members
1966                    .iter()
1967                    .map(|m| format!("'{}'", m.replace('\'', "''")))
1968                    .collect();
1969                steps.push(
1970                    OpKey::Scalar(e.module.clone(), e.name.clone()),
1971                    Verb::Create,
1972                    verbosename_scalar(&e.module, &e.name),
1973                    DiffOp {
1974                        sql: format!(
1975                            "DO $$ BEGIN CREATE TYPE {}.{} AS ENUM ({}); \
1976                         EXCEPTION WHEN duplicate_object THEN NULL; END $$;",
1977                            pg_schema(&e.module),
1978                            qi(&e.name),
1979                            members.join(", ")
1980                        ),
1981                        non_transactional: false,
1982                    },
1983                );
1984            }
1985            Some(existing) => {
1986                let existing_set: HashSet<&str> = existing.members.iter().map(|m| m.as_str()).collect();
1987                for member in &e.members {
1988                    if !existing_set.contains(member.as_str()) {
1989                        steps.push(
1990                            OpKey::Scalar(e.module.clone(), e.name.clone()),
1991                            Verb::Alter,
1992                            verbosename_scalar(&e.module, &e.name),
1993                            DiffOp {
1994                                sql: format!(
1995                                    "ALTER TYPE {}.{} ADD VALUE IF NOT EXISTS '{}';",
1996                                    pg_schema(&e.module),
1997                                    qi(&e.name),
1998                                    member.replace('\'', "''")
1999                                ),
2000                                non_transactional: false,
2001                            },
2002                        );
2003                    }
2004                }
2005            }
2006        }
2007    }
2008
2009    // ── Phase 2.5: sequences (for sequence scalars — folded into the same
2010    // scalar's step, see `OpKey::Scalar`'s doc comment) ──────────────────────
2011    for s in &target.scalars {
2012        if s.is_sequence {
2013            let seq_name = format!("{}_seq", s.name);
2014            if !cur_sequences.contains(&(s.module.as_str(), seq_name.as_str())) {
2015                let verb = if cur_domains.contains(&(s.module.as_str(), s.name.as_str())) {
2016                    Verb::Alter
2017                } else {
2018                    Verb::Create
2019                };
2020                steps.push(
2021                    OpKey::Scalar(s.module.clone(), s.name.clone()),
2022                    verb,
2023                    verbosename_scalar(&s.module, &s.name),
2024                    DiffOp {
2025                        sql: format!(
2026                            "CREATE SEQUENCE IF NOT EXISTS {}.{};",
2027                            pg_schema(&s.module),
2028                            qi(&seq_name)
2029                        ),
2030                        non_transactional: false,
2031                    },
2032                );
2033            }
2034        }
2035    }
2036
2037    // ── Phase 3: custom scalar domains ───────────────────────────────────────
2038    for s in &target.scalars {
2039        if !cur_domains.contains(&(s.module.as_str(), s.name.as_str())) {
2040            let check_clause = crate::export::scalar_check_clauses(target, &s.module, &s.name);
2041            steps.push(
2042                OpKey::Scalar(s.module.clone(), s.name.clone()),
2043                Verb::Create,
2044                verbosename_scalar(&s.module, &s.name),
2045                DiffOp {
2046                    sql: format!(
2047                        "DO $do$ BEGIN CREATE DOMAIN {}.{} AS {}{}; \
2048                     EXCEPTION WHEN duplicate_object THEN NULL; END $do$;",
2049                        pg_schema(&s.module),
2050                        qi(&s.name),
2051                        s.pg_type,
2052                        check_clause
2053                    ),
2054                    non_transactional: false,
2055                },
2056            );
2057        }
2058    }
2059
2060    // ── Phase 3.2: a existing domain's own CHECK constraints ─────────────────
2061    // `CREATE DOMAIN` runs once and never again, so without this an edited
2062    // `MaxLen`/`Regexp` on a custom scalar reached a fresh database and no
2063    // other. Pylon owns the domain outright, so any constraint on it that the
2064    // schema no longer names is one of its own leftovers.
2065    let expected_domain_checks = crate::export::scalar_check_constraints(target);
2066    for cur_domain in &current.domains {
2067        if !target
2068            .scalars
2069            .iter()
2070            .any(|s| s.module == cur_domain.schema && s.name == cur_domain.name)
2071        {
2072            continue; // the domain itself is going, or is not Pylon's
2073        }
2074        let wanted: Vec<&(String, String, String, String)> = expected_domain_checks
2075            .iter()
2076            .filter(|(module, name, _, _)| *module == cur_domain.schema && *name == cur_domain.name)
2077            .collect();
2078        let domain = format!("{}.{}", pg_schema(&cur_domain.schema), qi(&cur_domain.name));
2079        let unchanged = wanted.len() == cur_domain.checks.len()
2080            && wanted
2081                .iter()
2082                .all(|(_, _, cname, _)| cur_domain.checks.iter().any(|name| name == cname));
2083        if unchanged {
2084            continue;
2085        }
2086        let mut local: Vec<DiffOp> = Vec::new();
2087        // Clear the whole set rather than diffing it by name: Postgres names a
2088        // domain's inline constraints itself (`EmailAddress_check1`), so a
2089        // schema-derived baseline can never say what is actually there. Pylon
2090        // owns the domain, so reconciling all of it is safe and reproducible.
2091        push_tx(
2092            &mut local,
2093            format!(
2094                "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$;"
2095            ),
2096        );
2097        for (_, _, cname, expr) in wanted {
2098            push_tx(
2099                &mut local,
2100                format!("ALTER DOMAIN {} ADD CONSTRAINT {} CHECK ({});", domain, qi(cname), expr),
2101            );
2102        }
2103        if !local.is_empty() {
2104            steps.extend(
2105                OpKey::Scalar(cur_domain.schema.clone(), cur_domain.name.clone()),
2106                Verb::Alter,
2107                verbosename_scalar(&cur_domain.schema, &cur_domain.name),
2108                local,
2109            );
2110        }
2111    }
2112
2113    // ── Phase 3.5: scalar functions (before tables — table DEFAULTs may call them) ──
2114    let scalar_fn_ddls = crate::export::scalar_function_ddl_with_names(target).map_err(|e| e.to_string())?;
2115    for (module, name, ddl) in scalar_fn_ddls {
2116        let emit = if for_migration {
2117            let hash = ddl_hash(&ddl);
2118            cur_functions
2119                .get(&(module.as_str(), name.as_str()))
2120                .map(|&h| h != hash)
2121                .unwrap_or(true)
2122        } else {
2123            true
2124        };
2125        if emit {
2126            let verb = if cur_functions.contains_key(&(module.as_str(), name.as_str())) {
2127                Verb::Alter
2128            } else {
2129                Verb::Create
2130            };
2131            steps.push(
2132                OpKey::Function(module.clone(), name.clone()),
2133                verb,
2134                verbosename_function(&module, &name),
2135                DiffOp {
2136                    sql: ddl,
2137                    non_transactional: false,
2138                },
2139            );
2140        }
2141    }
2142
2143    // ── Phase 4 & 5: tables (create new or alter existing) ───────────────────
2144    let sort_order = topo_sort_types(&target.types, &polymorphic);
2145
2146    // Track which tables are created in this diff (needed for CONCURRENTLY decision).
2147    let mut new_tables: HashSet<(String, String)> = HashSet::new();
2148
2149    for &i in &sort_order {
2150        let td = &target.types[i];
2151        if td.abstract_ || td.junction {
2152            continue;
2153        }
2154        let key = (td.module.as_str(), td.table.as_str());
2155        match cur_tables.get(&key) {
2156            None => {
2157                let mut local: Vec<DiffOp> = Vec::new();
2158                emit_create_table(td, target, &mut local);
2159                steps.extend(
2160                    OpKey::Table(td.module.clone(), td.table.clone()),
2161                    Verb::Create,
2162                    verbosename_type(&td.module, &td.name),
2163                    local,
2164                );
2165                new_tables.insert((td.module.clone(), td.table.clone()));
2166            }
2167            Some(existing) => {
2168                let fill_cols = fill_index
2169                    .get(&(td.module.clone(), td.table.clone()))
2170                    .cloned()
2171                    .unwrap_or_default();
2172                let mut local: Vec<DiffOp> = Vec::new();
2173                let mut inputs: Vec<RequiredInput> = Vec::new();
2174                emit_column_diff(td, existing, &mut local, for_migration, &fill_cols, target, &mut inputs);
2175                steps.extend_with_input(
2176                    OpKey::Table(td.module.clone(), td.table.clone()),
2177                    Verb::Alter,
2178                    verbosename_type(&td.module, &td.name),
2179                    local,
2180                    inputs,
2181                );
2182            }
2183        }
2184    }
2185
2186    // ── Phase 6: FK constraints for new and existing tables (folded into the
2187    // same table step) ────────────────────────────────────────────────────────
2188    // Must run for brand-new tables too (`emit_create_table` only emits plain
2189    // `uuid` link columns, never a `REFERENCES` clause) — a table absent from
2190    // `cur_tables` still needs every one of its FKs added, just against an
2191    // empty "already has" set instead of an introspected one.
2192    for &i in &sort_order {
2193        let td = &target.types[i];
2194        if td.abstract_ || td.junction {
2195            continue;
2196        }
2197        let existing = cur_tables.get(&(td.module.as_str(), td.table.as_str())).copied();
2198        let verb = if new_tables.contains(&(td.module.clone(), td.table.clone())) {
2199            Verb::Create
2200        } else {
2201            Verb::Alter
2202        };
2203        let mut local: Vec<DiffOp> = Vec::new();
2204        emit_fk_diff(td, existing, &type_map, &polymorphic, &mut local);
2205        steps.extend(
2206            OpKey::ForeignKey(td.module.clone(), td.table.clone()),
2207            verb,
2208            verbosename_type(&td.module, &td.name),
2209            local,
2210        );
2211    }
2212
2213    // ── Phase 6b: junction target FKs ─────────────────────────────────────────
2214    // Keyed by the junction table rather than its owner, so these land in a
2215    // step of their own after every `CREATE TABLE` — the target of a junction
2216    // is an unrelated type whose table the owner's own step cannot depend on.
2217    for (jt_module, jt_name, cname, ddl) in crate::export::junction_fk_constraints(target, &type_map) {
2218        let already_there = cur_tables
2219            .get(&(jt_module.as_str(), jt_name.as_str()))
2220            .map(|t| t.foreign_keys.iter().any(|fk| fk.constraint_name == cname))
2221            .unwrap_or(false);
2222        if already_there {
2223            continue;
2224        }
2225        let verb = if cur_tables.contains_key(&(jt_module.as_str(), jt_name.as_str())) {
2226            Verb::Alter
2227        } else {
2228            Verb::Create
2229        };
2230        let mut local: Vec<DiffOp> = Vec::new();
2231        push_tx(&mut local, ddl);
2232        steps.extend(
2233            OpKey::ForeignKey(jt_module.clone(), jt_name.clone()),
2234            verb,
2235            format!("link table '{}.{}'", jt_module, jt_name),
2236            local,
2237        );
2238    }
2239
2240    // ── Phase 6c: CHECK constraints ───────────────────────────────────────────
2241    // This phase did not exist. `export_schema` emitted every CHECK the schema
2242    // implies and the migration path emitted none, so `pylon migration apply`
2243    // produced databases with no `MaxLen`/`MinValue`/`Regexp`/`OneOf`/
2244    // `Expression` enforcement at all — a 200-character value went straight
2245    // into a `MaxLen(24)` column.
2246    for (module, table, cname, ddl) in crate::export::check_constraints(target).map_err(|e| e.to_string())? {
2247        let already_there = cur_tables
2248            .get(&(module.as_str(), table.as_str()))
2249            .map(|t| t.checks.iter().any(|c| c.constraint_name == cname))
2250            .unwrap_or(false);
2251        if already_there {
2252            continue;
2253        }
2254        let verb = if cur_tables.contains_key(&(module.as_str(), table.as_str())) {
2255            Verb::Alter
2256        } else {
2257            Verb::Create
2258        };
2259        let mut local: Vec<DiffOp> = Vec::new();
2260        push_tx(&mut local, ddl);
2261        steps.extend(
2262            OpKey::ForeignKey(module.clone(), format!("{table}#checks")),
2263            verb,
2264            verbosename_type(&module, &table),
2265            local,
2266        );
2267    }
2268
2269    // ── Phase 7: junction tables for new multi-links (and junction-backed
2270    // single links, which share the exact same junction-table machinery,
2271    // just capped to one row per source) — folded into the owning type's
2272    // step, since a user thinks of this as "altering/creating the type to
2273    // add a multi-link", not as a separate table ────────────────────────────
2274    for &i in &sort_order {
2275        let td = &target.types[i];
2276        if td.abstract_ || td.junction {
2277            continue;
2278        }
2279        let owner_key = OpKey::Table(td.module.clone(), td.table.clone());
2280        let owner_verb = if new_tables.contains(&(td.module.clone(), td.table.clone())) {
2281            Verb::Create
2282        } else {
2283            Verb::Alter
2284        };
2285        let owner_desc = verbosename_type(&td.module, &td.name);
2286        for ml in &td.multilinks {
2287            let jt = format!("{}.{}", td.table, ml.name);
2288            if !cur_tables.contains_key(&(td.module.as_str(), jt.as_str())) {
2289                let mut local: Vec<DiffOp> = Vec::new();
2290                emit_junction_table(td, &ml.name, ml.through.as_deref(), target, false, &mut local);
2291                steps.extend(owner_key.clone(), owner_verb, owner_desc.clone(), local);
2292                new_tables.insert((td.module.clone(), jt));
2293            }
2294        }
2295        for l in &td.links {
2296            if !l.is_junction_backed() {
2297                continue;
2298            }
2299            let jt = format!("{}.{}", td.table, l.name);
2300            if !cur_tables.contains_key(&(td.module.as_str(), jt.as_str())) {
2301                let mut local: Vec<DiffOp> = Vec::new();
2302                emit_junction_table(td, &l.name, l.through.as_deref(), target, true, &mut local);
2303                steps.extend(owner_key.clone(), owner_verb, owner_desc.clone(), local);
2304                new_tables.insert((td.module.clone(), jt));
2305            }
2306        }
2307    }
2308
2309    // ── Phase 8: vector columns + indexes (folded into the owning type's step) ──
2310    for &i in &sort_order {
2311        let td = &target.types[i];
2312        if td.abstract_ || td.vector_indexes.is_empty() {
2313            continue;
2314        }
2315        let existing = cur_tables.get(&(td.module.as_str(), td.table.as_str()));
2316        let table_is_new = new_tables.contains(&(td.module.clone(), td.table.clone()));
2317        let owner_key = OpKey::Table(td.module.clone(), td.table.clone());
2318        let owner_verb = if table_is_new { Verb::Create } else { Verb::Alter };
2319        let owner_desc = verbosename_type(&td.module, &td.name);
2320
2321        for vi in &td.vector_indexes {
2322            let col = vi.column_name();
2323            if existing
2324                .map(|t| t.columns.iter().any(|c| c.name == col))
2325                .unwrap_or(false)
2326            {
2327                continue;
2328            }
2329            let mut local: Vec<DiffOp> = Vec::new();
2330            push_tx(
2331                &mut local,
2332                format!(
2333                    "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} vector({});",
2334                    qn(&td.module, &td.table),
2335                    qi(&col),
2336                    vi.dimensions
2337                ),
2338            );
2339            let idx_name = match &vi.index_name {
2340                None => format!("{}__vector__", td.table),
2341                Some(n) => format!("{}__vector_{}__", td.table, n),
2342            };
2343            // Pre-existing tables: CONCURRENTLY (non-transactional step in migration).
2344            // New tables: plain CREATE INDEX (no rows, no locking concern).
2345            let use_concurrently = for_migration && !table_is_new;
2346            let idx_sql = if use_concurrently {
2347                format!(
2348                    "CREATE INDEX CONCURRENTLY IF NOT EXISTS {} ON {} USING hnsw ({} {});",
2349                    qi(&idx_name),
2350                    qn(&td.module, &td.table),
2351                    qi(&col),
2352                    vi.ops_class()
2353                )
2354            } else {
2355                format!(
2356                    "CREATE INDEX IF NOT EXISTS {} ON {} USING hnsw ({} {});",
2357                    qi(&idx_name),
2358                    qn(&td.module, &td.table),
2359                    qi(&col),
2360                    vi.ops_class()
2361                )
2362            };
2363            local.push(DiffOp {
2364                sql: idx_sql,
2365                non_transactional: use_concurrently,
2366            });
2367            steps.extend(owner_key.clone(), owner_verb, owner_desc.clone(), local);
2368        }
2369    }
2370
2371    // ── Phase 9: search tsvector columns + GIN indexes (folded likewise) ─────
2372    for &i in &sort_order {
2373        let td = &target.types[i];
2374        if td.abstract_ || td.search_indexes.is_empty() {
2375            continue;
2376        }
2377        let existing = cur_tables.get(&(td.module.as_str(), td.table.as_str()));
2378        let table_is_new = new_tables.contains(&(td.module.clone(), td.table.clone()));
2379        let owner_key = OpKey::Table(td.module.clone(), td.table.clone());
2380        let owner_verb = if table_is_new { Verb::Create } else { Verb::Alter };
2381        let owner_desc = verbosename_type(&td.module, &td.name);
2382
2383        for si in &td.search_indexes {
2384            if si.backend != SearchBackend::Postgres {
2385                continue;
2386            }
2387            let col = si.column_name();
2388            if existing
2389                .map(|t| t.columns.iter().any(|c| c.name == col))
2390                .unwrap_or(false)
2391            {
2392                continue;
2393            }
2394            let mut local: Vec<DiffOp> = Vec::new();
2395            let parts: Vec<String> = si
2396                .pointers
2397                .iter()
2398                .map(|sf| {
2399                    format!(
2400                        "setweight(to_tsvector('english', coalesce({}, '')), '{}')",
2401                        qi(&sf.name),
2402                        sf.weight.as_str()
2403                    )
2404                })
2405                .collect();
2406            let expr = if parts.len() == 1 {
2407                parts.into_iter().next().unwrap()
2408            } else {
2409                parts.join(" || ")
2410            };
2411            push_tx(
2412                &mut local,
2413                format!(
2414                    "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} tsvector GENERATED ALWAYS AS ({}) STORED;",
2415                    qn(&td.module, &td.table),
2416                    qi(&col),
2417                    expr
2418                ),
2419            );
2420            let idx_name = match &si.index_name {
2421                None => format!("{}__search__", td.table),
2422                Some(n) => format!("{}__search_{}__", td.table, n),
2423            };
2424            let use_concurrently = for_migration && !table_is_new;
2425            let idx_sql = if use_concurrently {
2426                format!(
2427                    "CREATE INDEX CONCURRENTLY IF NOT EXISTS {} ON {} USING gin ({});",
2428                    qi(&idx_name),
2429                    qn(&td.module, &td.table),
2430                    qi(&col)
2431                )
2432            } else {
2433                format!(
2434                    "CREATE INDEX IF NOT EXISTS {} ON {} USING gin ({});",
2435                    qi(&idx_name),
2436                    qn(&td.module, &td.table),
2437                    qi(&col)
2438                )
2439            };
2440            local.push(DiffOp {
2441                sql: idx_sql,
2442                non_transactional: use_concurrently,
2443            });
2444            steps.extend(owner_key.clone(), owner_verb, owner_desc.clone(), local);
2445        }
2446    }
2447
2448    // ── Unique and plain indexes ─────────────────────────────────────────────
2449    // Only the vector and search passes above used to run here, so every
2450    // `Exclusive` and `Index` the schema declared existed on a fresh install
2451    // (`export_schema` emits them) and nowhere else: 283 exclusives in the
2452    // jaldis schema, none of them enforced in a migrated database.
2453    let expected_state = schema_to_db_state(target);
2454    let current_indexes: HashMap<(&str, &str), &[DbIndex]> = current
2455        .tables
2456        .iter()
2457        .map(|t| ((t.schema.as_str(), t.name.as_str()), t.indexes.as_slice()))
2458        .collect();
2459    for table in &expected_state.tables {
2460        let existing = current_indexes
2461            .get(&(table.schema.as_str(), table.name.as_str()))
2462            .copied()
2463            .unwrap_or_default();
2464        let table_is_new = !current_indexes.contains_key(&(table.schema.as_str(), table.name.as_str()));
2465        for idx in &table.indexes {
2466            // Vector and search indexes have neither; their own passes above
2467            // know the operator class and expression each needs.
2468            if idx.columns.is_empty() && idx.key.is_none() {
2469                continue;
2470            }
2471            // Postgres auto-names an index created without a name, so the
2472            // database's name is no guide for a plain column list -- what
2473            // settles that is the key itself: same columns, same uniqueness,
2474            // and partial or not. An index over an expression has no column
2475            // list to compare and is matched by the name this generates.
2476            let already_there = match &idx.key {
2477                Some(_) => existing.iter().any(|b| b.name == idx.name),
2478                None => existing.iter().any(|b| {
2479                    b.columns == idx.columns
2480                        && b.is_unique == idx.is_unique
2481                        && b.predicate.is_some() == idx.predicate.is_some()
2482                }),
2483            };
2484            if already_there {
2485                continue;
2486            }
2487            let use_concurrently = for_migration && !table_is_new;
2488            let concurrently = if use_concurrently { "CONCURRENTLY " } else { "" };
2489            let unique = if idx.is_unique { "UNIQUE " } else { "" };
2490            let body = match &idx.key {
2491                Some(key) => key.clone(),
2492                None => format!("({})", idx.columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ")),
2493            };
2494            let sql = format!(
2495                "CREATE {unique}INDEX {concurrently}IF NOT EXISTS {} ON {} {}{};",
2496                qi(&capped_index_name(idx.name.clone())),
2497                qn(&table.schema, &table.name),
2498                body,
2499                idx.predicate.clone().unwrap_or_default(),
2500            );
2501            let owner_key = OpKey::Table(table.schema.clone(), table.name.clone());
2502            let owner_verb = if table_is_new { Verb::Create } else { Verb::Alter };
2503            let owner_desc = verbosename_type(&table.schema, &table.name);
2504            steps.extend(
2505                owner_key,
2506                owner_verb,
2507                owner_desc,
2508                vec![DiffOp {
2509                    sql,
2510                    non_transactional: use_concurrently,
2511                }],
2512            );
2513        }
2514    }
2515
2516    // ── Phase 10: interface views (after all tables exist) ───────────────────
2517    // In watch mode always re-emit (CREATE OR REPLACE VIEW is idempotent).
2518    // In migration mode only emit new or changed views.
2519    for (module, name, ddl) in crate::export::interface_view_ddl_with_names(target) {
2520        let emit = if for_migration {
2521            let hash = ddl_hash(&ddl);
2522            cur_views
2523                .get(&(module.as_str(), name.as_str()))
2524                .map(|&h| h != hash)
2525                .unwrap_or(true)
2526        } else {
2527            true
2528        };
2529        if emit {
2530            let verb = if cur_views.contains_key(&(module.as_str(), name.as_str())) {
2531                Verb::Alter
2532            } else {
2533                Verb::Create
2534            };
2535            steps.push(
2536                OpKey::View(module.clone(), name.clone()),
2537                verb,
2538                verbosename_interface(&module, &name),
2539                DiffOp {
2540                    sql: ddl,
2541                    non_transactional: false,
2542                },
2543            );
2544        }
2545    }
2546
2547    // ── Phase 10.5: junction-backed exclusive link cross-implementor views ───
2548    // Same add/hash-diff treatment as Phase 10's interface object views —
2549    // see `export::interface_junction_view_ddl_with_names`'s own doc comment for
2550    // why a junction-backed exclusive link needs its own helper view at
2551    // all (its value lives in each implementor's own separate junction
2552    // table, never on the owner row). Must run before Phase 11.5, which
2553    // queries these views from the trigger functions it emits.
2554    for (module, name, ddl) in crate::export::interface_junction_view_ddl_with_names(target) {
2555        let emit = if for_migration {
2556            let hash = ddl_hash(&ddl);
2557            cur_views
2558                .get(&(module.as_str(), name.as_str()))
2559                .map(|&h| h != hash)
2560                .unwrap_or(true)
2561        } else {
2562            true
2563        };
2564        if emit {
2565            let verb = if cur_views.contains_key(&(module.as_str(), name.as_str())) {
2566                Verb::Alter
2567            } else {
2568                Verb::Create
2569            };
2570            steps.push(
2571                OpKey::View(module.clone(), name.clone()),
2572                verb,
2573                verbosename_interface(&module, &name),
2574                DiffOp {
2575                    sql: ddl,
2576                    non_transactional: false,
2577                },
2578            );
2579        }
2580    }
2581
2582    // ── Phase 10.6: drop views the schema no longer declares ─────────────────
2583    // An interface demoted to a mixin, or one whose junction-backed exclusive
2584    // link is gone, leaves a view nothing recreates — the same reasoning
2585    // Phase 12 applies to a table the schema stopped declaring.
2586    let target_views: HashSet<(String, String)> = crate::export::interface_view_ddl_with_names(target)
2587        .into_iter()
2588        .chain(crate::export::interface_junction_view_ddl_with_names(target))
2589        .map(|(module, name, _)| (module, name))
2590        .collect();
2591    for view in &current.views {
2592        if !target_views.contains(&(view.schema.clone(), view.name.clone())) {
2593            steps.push(
2594                OpKey::View(view.schema.clone(), view.name.clone()),
2595                Verb::Drop,
2596                verbosename_interface(&view.schema, &view.name),
2597                DiffOp {
2598                    sql: format!("DROP VIEW IF EXISTS {};", qn(&view.schema, &view.name)),
2599                    non_transactional: false,
2600                },
2601            );
2602        }
2603    }
2604
2605    // ── Phase 11: object-returning functions (after tables and views exist) ──
2606    let obj_fn_ddls = crate::export::object_function_ddl_with_names(target).map_err(|e| e.to_string())?;
2607    for (module, name, ddl) in obj_fn_ddls {
2608        let emit = if for_migration {
2609            let hash = ddl_hash(&ddl);
2610            cur_functions
2611                .get(&(module.as_str(), name.as_str()))
2612                .map(|&h| h != hash)
2613                .unwrap_or(true)
2614        } else {
2615            true
2616        };
2617        if emit {
2618            let verb = if cur_functions.contains_key(&(module.as_str(), name.as_str())) {
2619                Verb::Alter
2620            } else {
2621                Verb::Create
2622            };
2623            steps.push(
2624                OpKey::Function(module.clone(), name.clone()),
2625                verb,
2626                verbosename_function(&module, &name),
2627                DiffOp {
2628                    sql: ddl,
2629                    non_transactional: false,
2630                },
2631            );
2632        }
2633    }
2634
2635    // Every physical table (including multilink/junction-backed-link tables)
2636    // `target` still wants — computed here, ahead of Phase 11.5, so that
2637    // phase can tell a table being *fully dropped* (Phase 12) apart from one
2638    // that's merely losing a trigger. Reused as-is by Phase 12 itself below.
2639    let mut target_tables: HashSet<(String, String)> = HashSet::new();
2640    for td in &target.types {
2641        if !td.abstract_ {
2642            target_tables.insert((td.module.clone(), td.table.clone()));
2643        }
2644        if !td.abstract_ && !td.junction {
2645            for ml in &td.multilinks {
2646                target_tables.insert((td.module.clone(), format!("{}.{}", td.table, ml.name)));
2647            }
2648            for l in &td.links {
2649                if !l.is_junction_backed() {
2650                    continue;
2651                }
2652                target_tables.insert((td.module.clone(), format!("{}.{}", td.table, l.name)));
2653            }
2654        }
2655    }
2656
2657    // ── Phase 11.5: interface exclusive constraint triggers (folded into the
2658    // owning concrete table's step — a user thinks of these as part of
2659    // "altering type X", not as separate objects) ────────────────────────────
2660    {
2661        let infos = crate::export::interface_exclusive_trigger_infos(target);
2662        let cur_trigger_map: HashMap<(&str, &str), HashSet<&str>> = current
2663            .tables
2664            .iter()
2665            .map(|t| {
2666                (
2667                    (t.schema.as_str(), t.name.as_str()),
2668                    t.triggers.iter().map(|n| n.as_str()).collect::<HashSet<_>>(),
2669                )
2670            })
2671            .collect();
2672
2673        // What every table's trigger set *should* be once `target` is fully
2674        // applied — the single source of truth both the add-decisions below
2675        // and the final drop phase compare `current` against (see
2676        // `expected_triggers`'s own doc comment for why this must be one
2677        // shared computation, not two).
2678        let expected_trigger_map = expected_triggers(target, &type_map);
2679
2680        // A physical table's (module, name) -> the type whose step its
2681        // trigger changes should fold into. Concrete tables own themselves;
2682        // junction tables (a multi-link or junction-backed single link's own
2683        // physical table) fold into the type that declared that link.
2684        let owner_of = |module: &str, table: &str| -> (OpKey, Verb, String) {
2685            for &i in &sort_order {
2686                let td = &target.types[i];
2687                if td.abstract_ || td.junction {
2688                    continue;
2689                }
2690                let is_owner = td.module == module
2691                    && (td.table == table
2692                        || td
2693                            .multilinks
2694                            .iter()
2695                            .any(|ml| format!("{}.{}", td.table, ml.name) == table)
2696                        || td
2697                            .links
2698                            .iter()
2699                            .any(|l| l.is_junction_backed() && format!("{}.{}", td.table, l.name) == table));
2700                if is_owner {
2701                    let verb = if new_tables.contains(&(td.module.clone(), td.table.clone())) {
2702                        Verb::Create
2703                    } else {
2704                        Verb::Alter
2705                    };
2706                    return (
2707                        OpKey::Table(td.module.clone(), td.table.clone()),
2708                        verb,
2709                        verbosename_type(&td.module, &td.name),
2710                    );
2711                }
2712            }
2713            (
2714                OpKey::Table(module.to_string(), table.to_string()),
2715                Verb::Alter,
2716                verbosename_type(module, table),
2717            )
2718        };
2719
2720        struct PendingExclTrigger<'a> {
2721            info: &'a crate::export::ExclTriggerInfo,
2722            need_ins: bool,
2723            need_upd: bool,
2724            owner: (OpKey, Verb, String),
2725        }
2726
2727        let pending: Vec<PendingExclTrigger<'_>> = infos
2728            .iter()
2729            .filter_map(|info| {
2730                let cur = cur_trigger_map
2731                    .get(&(info.impl_module.as_str(), info.impl_table.as_str()))
2732                    .cloned()
2733                    .unwrap_or_default();
2734                let need_ins = !cur.contains(info.ins_trigger_name.as_str());
2735                let need_upd = !cur.contains(info.upd_trigger_name.as_str());
2736                (need_ins || need_upd).then(|| PendingExclTrigger {
2737                    info,
2738                    need_ins,
2739                    need_upd,
2740                    owner: owner_of(&info.impl_module, &info.impl_table),
2741                })
2742            })
2743            .collect();
2744
2745        // One guard function is shared by every implementor of the interface,
2746        // so it is emitted with a single implementor's step — and that has to
2747        // be whichever of them renders *first*, or the others' triggers name a
2748        // function that does not exist yet. Steps render in the order their
2749        // keys were inserted, so an implementor whose table was created
2750        // earlier in the topological order already sits ahead here; one whose
2751        // step does not exist yet can only be inserted by this phase, in
2752        // `pending` order.
2753        let mut fn_carrier: HashMap<String, usize> = HashMap::new();
2754        for (index, entry) in pending.iter().enumerate() {
2755            let position = steps.position(&entry.owner.0).unwrap_or(usize::MAX);
2756            let beats_carrier = match fn_carrier.get(&entry.info.fn_name) {
2757                Some(&carrier) => position < steps.position(&pending[carrier].owner.0).unwrap_or(usize::MAX),
2758                None => true,
2759            };
2760            if beats_carrier {
2761                fn_carrier.insert(entry.info.fn_name.clone(), index);
2762            }
2763        }
2764
2765        for (index, entry) in pending.into_iter().enumerate() {
2766            let mut local: Vec<DiffOp> = Vec::new();
2767            if fn_carrier.get(&entry.info.fn_name) == Some(&index) {
2768                push_tx(&mut local, entry.info.fn_ddl.clone());
2769            }
2770            if entry.need_ins {
2771                push_tx(&mut local, entry.info.ins_ddl.clone());
2772            }
2773            if entry.need_upd {
2774                push_tx(&mut local, entry.info.upd_ddl.clone());
2775            }
2776            let (key, verb, desc) = entry.owner;
2777            steps.extend(key, verb, desc, local);
2778        }
2779
2780        // Deletion-policy triggers (single-link Source-side DeleteTarget/
2781        // DeleteTargetIfOrphan, multilink Source-side same, multilink
2782        // Target-side DeleteSource) — previously only `export_schema`'s
2783        // fresh-install path emitted these; the incremental migration path
2784        // silently never did, so these `on_delete` policies never took
2785        // effect for anything added via a real migration (confirmed live,
2786        // `tests/live_execution_on_delete.rs`, before this fix).
2787        for info in crate::export::deletion_policy_trigger_infos(target, &type_map) {
2788            let cur = cur_trigger_map
2789                .get(&(info.table_module.as_str(), info.table_name.as_str()))
2790                .cloned()
2791                .unwrap_or_default();
2792            if !cur.contains(info.trigger_name.as_str()) {
2793                let (key, verb, desc) = owner_of(&info.table_module, &info.table_name);
2794                steps.extend(
2795                    key,
2796                    verb,
2797                    desc,
2798                    vec![DiffOp {
2799                        sql: info.ddl.clone(),
2800                        non_transactional: false,
2801                    }],
2802                );
2803            }
2804        }
2805
2806        // `@pylon.signal` capture triggers — only for types with a
2807        // non-empty `signals` list (see `export::signal_trigger_infos`).
2808        // Adding/removing a signal handler changes the combined `on=`
2809        // bitmask the walker attaches to the schema, so this naturally
2810        // adds/drops the trigger on the next migration, same as any other
2811        // schema change — no separate sync step needed.
2812        for info in crate::export::signal_trigger_infos(target) {
2813            let cur = cur_trigger_map
2814                .get(&(info.table_module.as_str(), info.table_name.as_str()))
2815                .cloned()
2816                .unwrap_or_default();
2817            if !cur.contains(info.trigger_name.as_str()) {
2818                let (key, verb, desc) = owner_of(&info.table_module, &info.table_name);
2819                steps.extend(
2820                    key,
2821                    verb,
2822                    desc,
2823                    vec![DiffOp {
2824                        sql: info.ddl.clone(),
2825                        non_transactional: false,
2826                    }],
2827                );
2828            }
2829        }
2830
2831        // User-declared schema `Trigger`s — same previously-missing-parity
2832        // bug as deletion-policy triggers above: only `export_schema`'s
2833        // fresh-install path used to emit these at all (`emit_triggers` was
2834        // never called from the incremental migration path), so adding or
2835        // changing a `Trigger(...)` on an existing type via `migration
2836        // create` silently did nothing. A changed handler changes this
2837        // trigger's hash-derived name (`export::trigger_ddl_name`), so an
2838        // in-place edit surfaces here as "add the new name" — the stale
2839        // old-named trigger is caught by the generic "drop what's no longer
2840        // expected" sweep below, using the same `expected_trigger_map`.
2841        for info in crate::export::user_trigger_infos(target).map_err(|e| e.to_string())? {
2842            let cur = cur_trigger_map
2843                .get(&(info.table_module.as_str(), info.table_name.as_str()))
2844                .cloned()
2845                .unwrap_or_default();
2846            if !cur.contains(info.trigger_name.as_str()) {
2847                let (key, verb, desc) = owner_of(&info.table_module, &info.table_name);
2848                steps.extend(
2849                    key,
2850                    verb,
2851                    desc,
2852                    vec![DiffOp {
2853                        sql: info.ddl.clone(),
2854                        non_transactional: false,
2855                    }],
2856                );
2857            }
2858        }
2859
2860        // Cache-invalidation trigger — every concrete table and every
2861        // multi-link junction table, unconditionally (not gated by
2862        // `[cache].enabled`; see the cache layer plan's design decision).
2863        // Only the per-table attachment is migration content — the trigger
2864        // *function* itself ships via bootstrap DDL
2865        // (`stdlib::ddl::CACHE_INVALIDATE_DDL`), applied once by
2866        // `pylon database initialize`, not per-migration.
2867        // A HashSet, not a Vec — a junction ("through") type's own `td.table`
2868        // is the physical join table itself, identical to what the owning
2869        // type's multilink enumeration below also derives (e.g. `ProductTag`
2870        // and `Product`'s "tags" multilink both resolve to
2871        // `"Product.tags"`), so without deduping this would emit the same
2872        // `CREATE TRIGGER` statement twice for that table.
2873        let mut cache_trigger_tables: HashSet<(String, String)> = HashSet::new();
2874        for td in &target.types {
2875            if td.abstract_ || junction_table_belongs_to_an_interface(target, td) {
2876                continue;
2877            }
2878            cache_trigger_tables.insert((td.module.clone(), td.table.clone()));
2879            if !td.junction {
2880                for ml in &td.multilinks {
2881                    cache_trigger_tables.insert((td.module.clone(), format!("{}.{}", td.table, ml.name)));
2882                }
2883                for l in &td.links {
2884                    if !l.is_junction_backed() {
2885                        continue;
2886                    }
2887                    cache_trigger_tables.insert((td.module.clone(), format!("{}.{}", td.table, l.name)));
2888                }
2889            }
2890        }
2891        for (module, table) in &cache_trigger_tables {
2892            let already_present = cur_trigger_map
2893                .get(&(module.as_str(), table.as_str()))
2894                .map(|t| t.contains("pylon_cache_invalidate"))
2895                .unwrap_or(false);
2896            if !already_present {
2897                let (key, verb, desc) = owner_of(module, table);
2898                steps.extend(
2899                    key,
2900                    verb,
2901                    desc,
2902                    vec![DiffOp {
2903                        sql: cache_invalidate_trigger_sql(&qn(module, table)),
2904                        non_transactional: false,
2905                    }],
2906                );
2907            }
2908        }
2909
2910        // Drop triggers that no longer exist in the target schema — but not
2911        // for a table that's being dropped in its entirety (Phase 12): that
2912        // table's whole existence, triggers included, will be removed via
2913        // `DROP TABLE ... CASCADE`, so an explicit `DROP TRIGGER` here is
2914        // both redundant and actively wrong — `owner_of` has no real target
2915        // type to fold it into for such a table, so it fell back to a bare
2916        // `Verb::Alter` default, which then won the "first write wins" race
2917        // for this step's verb/prompt against Phase 12's own correct
2918        // `Verb::Drop` (confirmed live: `migration create` asked "did you
2919        // *alter* object type 'Widget'?" for a table being fully dropped).
2920        for cur_table in &current.tables {
2921            let key = (cur_table.schema.clone(), cur_table.name.clone());
2922            if !target_tables.contains(&key) {
2923                continue;
2924            }
2925            let expected = expected_trigger_map.get(&key).cloned().unwrap_or_default();
2926            for trigger_name in &cur_table.triggers {
2927                if !expected.contains(trigger_name) {
2928                    let (owner_key, verb, desc) = owner_of(&cur_table.schema, &cur_table.name);
2929                    steps.extend(
2930                        owner_key,
2931                        verb,
2932                        desc,
2933                        vec![
2934                            DiffOp {
2935                                sql: format!(
2936                                    "DROP TRIGGER IF EXISTS {} ON {};",
2937                                    qi(trigger_name),
2938                                    qn(&cur_table.schema, &cur_table.name)
2939                                ),
2940                                non_transactional: false,
2941                            },
2942                            // Generated triggers name their function after
2943                            // themselves, so dropping one leaves an
2944                            // unreferenced function behind. That was rare
2945                            // while trigger names were stable; now that a
2946                            // name follows its body (see `trigger_names` in
2947                            // `export`), every codegen change renames, and
2948                            // the orphans would accumulate — and read as
2949                            // live triggers to anyone introspecting.
2950                            //
2951                            // A no-op for triggers that don't follow that
2952                            // convention: the cache-invalidate trigger's
2953                            // function lives in `_pylon`, and the two
2954                            // interface-exclusive triggers (`..._ins`,
2955                            // `..._upd`) share one function named after
2956                            // neither. Nothing shared is reachable by this
2957                            // name, so this can't drop a function another
2958                            // trigger still needs.
2959                            DiffOp {
2960                                sql: format!("DROP FUNCTION IF EXISTS {}();", qn(&cur_table.schema, trigger_name)),
2961                                non_transactional: false,
2962                            },
2963                        ],
2964                    );
2965                }
2966            }
2967        }
2968    }
2969
2970    // ── Phase 11.9: drop CHECK constraints the schema no longer declares ─────
2971    // A check's name carries a hash of its own predicate, so editing one adds a
2972    // second constraint rather than replacing the first, and both then have to
2973    // hold — a column whose check was corrected would reject every value. Only
2974    // Pylon's own `…_<hash>_check` names are dropped; a constraint added by
2975    // hand is left alone.
2976    let expected_checks: HashSet<(String, String, String)> = crate::export::check_constraints(target)
2977        .map_err(|e| e.to_string())?
2978        .into_iter()
2979        .map(|(module, table, name, _)| (module, table, name))
2980        .collect();
2981    for cur_table in &current.tables {
2982        if !target_tables.contains(&(cur_table.schema.clone(), cur_table.name.clone())) {
2983            continue; // Phase 12 drops the whole table.
2984        }
2985        for check in &cur_table.checks {
2986            if !is_generated_check_name(&check.constraint_name) {
2987                continue;
2988            }
2989            let key = (
2990                cur_table.schema.clone(),
2991                cur_table.name.clone(),
2992                check.constraint_name.clone(),
2993            );
2994            if expected_checks.contains(&key) {
2995                continue;
2996            }
2997            steps.extend(
2998                OpKey::ForeignKey(cur_table.schema.clone(), format!("{}#checks", cur_table.name)),
2999                Verb::Alter,
3000                verbosename_type(&cur_table.schema, &cur_table.name),
3001                vec![DiffOp {
3002                    sql: format!(
3003                        "ALTER TABLE {} DROP CONSTRAINT IF EXISTS {};",
3004                        qn(&cur_table.schema, &cur_table.name),
3005                        qi(&check.constraint_name)
3006                    ),
3007                    non_transactional: false,
3008                }],
3009            );
3010        }
3011    }
3012
3013    // A link whose target became polymorphic (a concrete type gaining a
3014    // subtype) loses its foreign key: the subtype's rows live in another table.
3015    let polymorphic_targets = crate::export::polymorphic_types(target);
3016    let mut unwanted_fks: HashSet<(String, String, String)> = HashSet::new();
3017    for t in target.types.iter().filter(|t| !t.abstract_ && !t.junction) {
3018        for l in &t.links {
3019            if !polymorphic_targets.contains(&l.target) {
3020                continue;
3021            }
3022            unwanted_fks.insert(if l.is_junction_backed() {
3023                (
3024                    t.module.clone(),
3025                    format!("{}.{}", t.table, l.name),
3026                    format!("{}_{}_target_fkey", t.table, l.name),
3027                )
3028            } else {
3029                (
3030                    t.module.clone(),
3031                    t.table.clone(),
3032                    format!("{}_{}_fkey", t.table, l.name),
3033                )
3034            });
3035        }
3036        for ml in t
3037            .multilinks
3038            .iter()
3039            .filter(|ml| polymorphic_targets.contains(&ml.target))
3040        {
3041            unwanted_fks.insert((
3042                t.module.clone(),
3043                format!("{}.{}", t.table, ml.name),
3044                format!("{}_{}_target_fkey", t.table, ml.name),
3045            ));
3046        }
3047    }
3048    for cur_table in &current.tables {
3049        for fk in &cur_table.foreign_keys {
3050            let key = (
3051                cur_table.schema.clone(),
3052                cur_table.name.clone(),
3053                fk.constraint_name.clone(),
3054            );
3055            // A key into an interface's view was never created to begin with.
3056            let references_a_table = target
3057                .types
3058                .iter()
3059                .any(|t| !t.abstract_ && t.module == fk.ref_schema && t.table == fk.ref_table);
3060            if !unwanted_fks.contains(&key) || !references_a_table {
3061                continue;
3062            }
3063            steps.extend(
3064                OpKey::ForeignKey(cur_table.schema.clone(), cur_table.name.clone()),
3065                Verb::Alter,
3066                verbosename_type(&cur_table.schema, &cur_table.name),
3067                vec![DiffOp {
3068                    sql: format!(
3069                        "ALTER TABLE {} DROP CONSTRAINT IF EXISTS {};",
3070                        qn(&cur_table.schema, &cur_table.name),
3071                        qi(&fk.constraint_name)
3072                    ),
3073                    non_transactional: false,
3074                }],
3075            );
3076        }
3077    }
3078
3079    // ── Phase 12: drop removed tables ────────────────────────────────────────
3080    for cur_table in &current.tables {
3081        let key = (cur_table.schema.clone(), cur_table.name.clone());
3082        if !target_tables.contains(&key) {
3083            steps.push(
3084                OpKey::Table(cur_table.schema.clone(), cur_table.name.clone()),
3085                Verb::Drop,
3086                verbosename_type(&cur_table.schema, &cur_table.name),
3087                DiffOp {
3088                    sql: format!(
3089                        "DROP TABLE IF EXISTS {} CASCADE;",
3090                        qn(&cur_table.schema, &cur_table.name)
3091                    ),
3092                    non_transactional: false,
3093                },
3094            );
3095        }
3096    }
3097
3098    // ── Phase 13: drop removed enums ─────────────────────────────────────────
3099    let target_enum_set: HashSet<(String, String)> = target
3100        .enums
3101        .iter()
3102        .map(|e| (e.module.clone(), e.name.clone()))
3103        .collect();
3104    for cur_enum in &current.enums {
3105        if !target_enum_set.contains(&(cur_enum.schema.clone(), cur_enum.name.clone())) {
3106            steps.push(
3107                OpKey::Scalar(cur_enum.schema.clone(), cur_enum.name.clone()),
3108                Verb::Drop,
3109                verbosename_scalar(&cur_enum.schema, &cur_enum.name),
3110                DiffOp {
3111                    sql: format!(
3112                        "DROP TYPE IF EXISTS {}.{} CASCADE;",
3113                        pg_schema(&cur_enum.schema),
3114                        qi(&cur_enum.name)
3115                    ),
3116                    non_transactional: false,
3117                },
3118            );
3119        }
3120    }
3121
3122    // ── Phase 14: drop removed domains ───────────────────────────────────────
3123    let target_domain_set: HashSet<(String, String)> = target
3124        .scalars
3125        .iter()
3126        .map(|s| (s.module.clone(), s.name.clone()))
3127        .collect();
3128    for cur_domain in &current.domains {
3129        if !target_domain_set.contains(&(cur_domain.schema.clone(), cur_domain.name.clone())) {
3130            steps.push(
3131                OpKey::Scalar(cur_domain.schema.clone(), cur_domain.name.clone()),
3132                Verb::Drop,
3133                verbosename_scalar(&cur_domain.schema, &cur_domain.name),
3134                DiffOp {
3135                    sql: format!(
3136                        "DROP DOMAIN IF EXISTS {}.{} CASCADE;",
3137                        pg_schema(&cur_domain.schema),
3138                        qi(&cur_domain.name)
3139                    ),
3140                    non_transactional: false,
3141                },
3142            );
3143        }
3144    }
3145
3146    // ── Phase 14.5: drop removed sequences (folded into their scalar's drop
3147    // step by name, matching phase 2.5's own OpKey::Scalar grouping) ────────
3148    let target_sequence_set: HashSet<(String, String)> = target
3149        .scalars
3150        .iter()
3151        .filter(|s| s.is_sequence)
3152        .map(|s| (s.module.clone(), format!("{}_seq", s.name)))
3153        .collect();
3154    for cur_seq in &current.sequences {
3155        if !target_sequence_set.contains(&(cur_seq.schema.clone(), cur_seq.name.clone())) {
3156            let scalar_name = cur_seq.name.strip_suffix("_seq").unwrap_or(&cur_seq.name).to_string();
3157            steps.push(
3158                OpKey::Scalar(cur_seq.schema.clone(), scalar_name.clone()),
3159                Verb::Drop,
3160                verbosename_scalar(&cur_seq.schema, &scalar_name),
3161                DiffOp {
3162                    sql: format!(
3163                        "DROP SEQUENCE IF EXISTS {}.{};",
3164                        pg_schema(&cur_seq.schema),
3165                        qi(&cur_seq.name)
3166                    ),
3167                    non_transactional: false,
3168                },
3169            );
3170        }
3171    }
3172
3173    // ── Phase 15: drop removed modules ───────────────────────────────────────
3174    for module in &current.schemas {
3175        if module == "default" {
3176            continue;
3177        } // never drop public
3178        if !target_schemas.contains(module) {
3179            steps.push(
3180                OpKey::Module(module.clone()),
3181                Verb::Drop,
3182                verbosename_module(module),
3183                DiffOp {
3184                    sql: format!("DROP SCHEMA IF EXISTS {} CASCADE;", pg_schema(module)),
3185                    non_transactional: false,
3186                },
3187            );
3188        }
3189    }
3190
3191    Ok(steps.finish())
3192}
3193
3194fn push_tx(ops: &mut Vec<DiffOp>, sql: String) {
3195    ops.push(DiffOp {
3196        sql,
3197        non_transactional: false,
3198    });
3199}
3200
3201// ── Default resolution ────────────────────────────────────────────────────────
3202
3203/// Return the effective SQL DEFAULT for a property, compiling `default_pyql`
3204/// with the schema IR compiler if needed. `None` when a column DEFAULT cannot
3205/// hold it — `compile_insert` expands that one into the insert instead.
3206fn resolve_default(p: &crate::schema::PropertyDescriptor, schema: &SchemaDescriptor) -> Option<String> {
3207    if let Some(sql) = &p.default_sql {
3208        return Some(sql.clone());
3209    }
3210    if let Some(pyql) = &p.default_pyql {
3211        return crate::ir::column_default_sql(pyql, schema);
3212    }
3213    None
3214}
3215
3216/// Test-only accessor so `export` can assert its DDL matches what a
3217/// migration would produce for the same property.
3218#[cfg(test)]
3219pub(crate) fn resolve_default_for_test(
3220    p: &crate::schema::PropertyDescriptor,
3221    schema: &SchemaDescriptor,
3222) -> Option<String> {
3223    resolve_default(p, schema)
3224}
3225
3226fn resolve_link_default(l: &crate::schema::LinkDescriptor, schema: &SchemaDescriptor) -> Option<String> {
3227    if let Some(pyql) = &l.default_pyql {
3228        return crate::ir::column_default_sql(pyql, schema);
3229    }
3230    None
3231}
3232
3233// ── CREATE TABLE for a new type ───────────────────────────────────────────────
3234
3235fn emit_create_table(td: &TypeDescriptor, schema: &SchemaDescriptor, ops: &mut Vec<DiffOp>) {
3236    let mut lines: Vec<String> = Vec::new();
3237    for p in &td.properties {
3238        let not_null = if p.nullable { "" } else { " NOT NULL" };
3239        let default = resolve_default(p, schema)
3240            .map(|d| format!(" DEFAULT {}", d))
3241            .unwrap_or_default();
3242        lines.push(format!(
3243            "    {} {}{}{}",
3244            qi(&p.name),
3245            col_type_str(p),
3246            not_null,
3247            default
3248        ));
3249    }
3250    for l in &td.links {
3251        if l.is_junction_backed() {
3252            continue;
3253        }
3254        let not_null = if l.nullable { "" } else { " NOT NULL" };
3255        let default = resolve_link_default(l, schema)
3256            .map(|d| format!(" DEFAULT {}", d))
3257            .unwrap_or_default();
3258        lines.push(format!(
3259            "    {} uuid{}{}",
3260            qi(&format!("{}_id", l.name)),
3261            not_null,
3262            default
3263        ));
3264    }
3265    let pk_cols: Vec<String> = td.properties.iter().filter(|p| p.is_pk).map(|p| qi(&p.name)).collect();
3266    if !pk_cols.is_empty() {
3267        lines.push(format!("    PRIMARY KEY ({})", pk_cols.join(", ")));
3268    }
3269    push_tx(
3270        ops,
3271        format!(
3272            "CREATE TABLE IF NOT EXISTS {} (\n{}\n);",
3273            qn(&td.module, &td.table),
3274            lines.join(",\n")
3275        ),
3276    );
3277}
3278
3279/// `CREATE OR REPLACE TRIGGER` statement wiring `qualified_table` into the
3280/// cache-invalidation notify function (see `stdlib::ddl::CACHE_INVALIDATE_DDL`).
3281/// Statement-level, not row-level — tier 1 invalidation only needs one notify
3282/// per write statement.
3283fn cache_invalidate_trigger_sql(qualified_table: &str) -> String {
3284    format!(
3285        "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();",
3286        qualified_table
3287    )
3288}
3289
3290// ── Column diff for an existing table ─────────────────────────────────────────
3291
3292/// `for_migration`: true = migration-file mode, false = watch mode.
3293/// `fill_cols`: column names that will be backfilled via a fill expression.
3294///   For migration mode, new NOT NULL columns in this set are added as nullable
3295///   first (the fill + SET NOT NULL comes later); in watch mode fills are unused.
3296fn emit_column_diff(
3297    td: &TypeDescriptor,
3298    existing: &DbTable,
3299    ops: &mut Vec<DiffOp>,
3300    for_migration: bool,
3301    fill_cols: &HashSet<String>,
3302    schema: &SchemaDescriptor,
3303    required_input: &mut Vec<RequiredInput>,
3304) {
3305    let existing_col_map: HashMap<&str, &DbColumn> = existing.columns.iter().map(|c| (c.name.as_str(), c)).collect();
3306
3307    // ── Add new columns ───────────────────────────────────────────────────────
3308    for p in &td.properties {
3309        if existing_col_map.contains_key(p.name.as_str()) {
3310            continue;
3311        }
3312        let eff_default = resolve_default(p, schema);
3313        let needs_fill = for_migration && !p.nullable && eff_default.is_none() && fill_cols.contains(&p.name);
3314        let not_null = if p.nullable || needs_fill { "" } else { " NOT NULL" };
3315        let default = eff_default.map(|d| format!(" DEFAULT {}", d)).unwrap_or_default();
3316        push_tx(
3317            ops,
3318            format!(
3319                "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} {}{}{};",
3320                qn(&td.module, &td.table),
3321                qi(&p.name),
3322                col_type_str(p),
3323                not_null,
3324                default
3325            ),
3326        );
3327    }
3328    for l in &td.links {
3329        if l.is_junction_backed() {
3330            continue;
3331        }
3332        let col = format!("{}_id", l.name);
3333        if existing_col_map.contains_key(col.as_str()) {
3334            continue;
3335        }
3336        let eff_default = resolve_link_default(l, schema);
3337        let needs_fill = for_migration && !l.nullable && eff_default.is_none() && fill_cols.contains(&col);
3338        let not_null = if l.nullable || needs_fill { "" } else { " NOT NULL" };
3339        let default = eff_default.map(|d| format!(" DEFAULT {}", d)).unwrap_or_default();
3340        push_tx(
3341            ops,
3342            format!(
3343                "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} uuid{}{};",
3344                qn(&td.module, &td.table),
3345                qi(&col),
3346                not_null,
3347                default
3348            ),
3349        );
3350    }
3351
3352    // ── Type changes on existing columns ──────────────────────────────────────
3353    // e.g. a property gaining a registered custom scalar's own DOMAIN (see
3354    // PropertyDescriptor.column_type), or any other base-type change.
3355    let type_changes: Vec<(&str, &str, bool)> = td
3356        .properties
3357        .iter()
3358        .filter_map(|p| {
3359            let cur = existing_col_map.get(p.name.as_str())?;
3360            if cur.is_generated {
3361                return None;
3362            }
3363            let target_type = col_type_str(p);
3364            pg_type_changed(target_type, &cur.pg_type).then_some((
3365                p.name.as_str(),
3366                target_type,
3367                cur.column_default.is_some(),
3368            ))
3369        })
3370        .collect();
3371    if !type_changes.is_empty() {
3372        // A column any interface view selects from can't be retyped directly
3373        // — Postgres refuses ALTER COLUMN TYPE while a view depends on it —
3374        // so drop those views first and recreate them (identical DDL,
3375        // unaffected by a column's own type) right after.
3376        let affected_views: Vec<(String, String, String)> = crate::export::interface_view_ddl_with_names(schema)
3377            .into_iter()
3378            .filter(|(m, n, _)| td.interfaces.contains(&format!("{}::{}", m, n)))
3379            .collect();
3380        for (m, n, _) in &affected_views {
3381            push_tx(ops, format!("DROP VIEW IF EXISTS {};", qn(m, n)));
3382        }
3383        for (col, target_type, has_default) in &type_changes {
3384            // Postgres checks the *existing* DEFAULT against the new type
3385            // before any `SET DEFAULT` further down can replace it, and
3386            // refuses ("default for column ... cannot be cast automatically").
3387            // Dropping it first leaves the column bare for exactly as long as
3388            // the type change takes; the new default is set below.
3389            if *has_default {
3390                push_tx(
3391                    ops,
3392                    format!(
3393                        "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT;",
3394                        qn(&td.module, &td.table),
3395                        qi(col)
3396                    ),
3397                );
3398            }
3399            // The conversion expression is a placeholder, not a blind cast —
3400            // an interactive caller offers `default_expr` (identical to what
3401            // this used to emit unconditionally) and lets the user override
3402            // it with their own PyQL expression before substituting it in;
3403            // `diff_schema_ops`/`watch` callers that never resolve any
3404            // `required_input` still get the exact same default behavior via
3405            // `RequiredInput::default_expr`-as-fallback at the CLI layer.
3406            let placeholder = format!("cast_expr__{col}");
3407            // PostgreSQL has no enum-to-enum cast, so a column moving between
3408            // two enums (a renamed one, most often) has to go through `text`.
3409            // Only for an enum target: a numeric conversion routed through
3410            // text would change what it accepts.
3411            let default_expr = if enum_target(schema, target_type) {
3412                let text_type = if target_type.ends_with("[]") { "text[]" } else { "text" };
3413                format!("{}::{text_type}::{target_type}", qi(col))
3414            } else {
3415                format!("{}::{target_type}", qi(col))
3416            };
3417            required_input.push(RequiredInput {
3418                placeholder: placeholder.clone(),
3419                prompt: format!(
3420                    "Please specify a conversion expression to alter the type of property '{col}' of {}",
3421                    verbosename_type(&td.module, &td.name),
3422                ),
3423                default_expr,
3424                type_name: format!("{}::{}", td.module, td.name),
3425            });
3426            push_tx(
3427                ops,
3428                format!(
3429                    "ALTER TABLE {} ALTER COLUMN {} TYPE {} USING \\({});",
3430                    qn(&td.module, &td.table),
3431                    qi(col),
3432                    target_type,
3433                    placeholder
3434                ),
3435            );
3436        }
3437        for (_, _, ddl) in &affected_views {
3438            push_tx(ops, ddl.clone());
3439        }
3440    }
3441
3442    // ── Nullability + DEFAULT changes on existing columns ─────────────────────
3443    for p in &td.properties {
3444        let Some(cur) = existing_col_map.get(p.name.as_str()) else {
3445            continue;
3446        };
3447        if cur.is_generated {
3448            continue;
3449        }
3450        if !cur.nullable && p.nullable {
3451            // NOT NULL → nullable: always safe, no fill needed.
3452            push_tx(
3453                ops,
3454                format!(
3455                    "ALTER TABLE {} ALTER COLUMN {} DROP NOT NULL;",
3456                    qn(&td.module, &td.table),
3457                    qi(&p.name)
3458                ),
3459            );
3460        } else if cur.nullable && !p.nullable && !for_migration {
3461            // nullable → NOT NULL: safe in watch mode (dev DB, typically no rows).
3462            push_tx(
3463                ops,
3464                format!(
3465                    "ALTER TABLE {} ALTER COLUMN {} SET NOT NULL;",
3466                    qn(&td.module, &td.table),
3467                    qi(&p.name)
3468                ),
3469            );
3470            // In migration mode this is intentionally skipped; the fill mechanism
3471            // emits UPDATE + SET NOT NULL after the main diff body.
3472        }
3473
3474        // DEFAULT changes
3475        let target_default = resolve_default(p, schema);
3476        let db_default = cur.column_default.as_deref();
3477        match (&target_default, db_default) {
3478            (Some(want), Some(have)) if want != have => {
3479                push_tx(
3480                    ops,
3481                    format!(
3482                        "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {};",
3483                        qn(&td.module, &td.table),
3484                        qi(&p.name),
3485                        want
3486                    ),
3487                );
3488            }
3489            (Some(want), None) => {
3490                push_tx(
3491                    ops,
3492                    format!(
3493                        "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {};",
3494                        qn(&td.module, &td.table),
3495                        qi(&p.name),
3496                        want
3497                    ),
3498                );
3499            }
3500            (None, Some(_)) => {
3501                push_tx(
3502                    ops,
3503                    format!(
3504                        "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT;",
3505                        qn(&td.module, &td.table),
3506                        qi(&p.name)
3507                    ),
3508                );
3509            }
3510            _ => {}
3511        }
3512    }
3513    for l in &td.links {
3514        if l.is_junction_backed() {
3515            continue;
3516        }
3517        let col = format!("{}_id", l.name);
3518        let Some(cur) = existing_col_map.get(col.as_str()) else {
3519            continue;
3520        };
3521        if !cur.nullable && l.nullable {
3522            push_tx(
3523                ops,
3524                format!(
3525                    "ALTER TABLE {} ALTER COLUMN {} DROP NOT NULL;",
3526                    qn(&td.module, &td.table),
3527                    qi(&col)
3528                ),
3529            );
3530        } else if cur.nullable && !l.nullable && !for_migration {
3531            push_tx(
3532                ops,
3533                format!(
3534                    "ALTER TABLE {} ALTER COLUMN {} SET NOT NULL;",
3535                    qn(&td.module, &td.table),
3536                    qi(&col)
3537                ),
3538            );
3539        }
3540
3541        let target_default = resolve_link_default(l, schema);
3542        let db_default = cur.column_default.as_deref();
3543        match (&target_default, db_default) {
3544            (Some(want), Some(have)) if want != have => {
3545                push_tx(
3546                    ops,
3547                    format!(
3548                        "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {};",
3549                        qn(&td.module, &td.table),
3550                        qi(&col),
3551                        want
3552                    ),
3553                );
3554            }
3555            (Some(want), None) => {
3556                push_tx(
3557                    ops,
3558                    format!(
3559                        "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {};",
3560                        qn(&td.module, &td.table),
3561                        qi(&col),
3562                        want
3563                    ),
3564                );
3565            }
3566            (None, Some(_)) => {
3567                push_tx(
3568                    ops,
3569                    format!(
3570                        "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT;",
3571                        qn(&td.module, &td.table),
3572                        qi(&col)
3573                    ),
3574                );
3575            }
3576            _ => {}
3577        }
3578    }
3579
3580    // ── Drop removed columns ──────────────────────────────────────────────────
3581    let target_cols: HashSet<String> = td
3582        .properties
3583        .iter()
3584        .map(|p| p.name.clone())
3585        .chain(
3586            td.links
3587                .iter()
3588                .filter(|l| !l.is_junction_backed())
3589                .map(|l| format!("{}_id", l.name)),
3590        )
3591        .collect();
3592    for col in &existing.columns {
3593        let n = col.name.as_str();
3594        if target_cols.contains(n) {
3595            continue;
3596        }
3597        if n.starts_with("__") && n.ends_with("__") {
3598            continue;
3599        }
3600        push_tx(
3601            ops,
3602            format!(
3603                "ALTER TABLE {} DROP COLUMN IF EXISTS {};",
3604                qn(&td.module, &td.table),
3605                qi(n)
3606            ),
3607        );
3608    }
3609}
3610
3611// ── FK diff for an existing table ─────────────────────────────────────────────
3612
3613fn emit_fk_diff(
3614    td: &TypeDescriptor,
3615    existing: Option<&DbTable>,
3616    type_map: &HashMap<String, (&str, &str)>,
3617    polymorphic: &HashSet<String>,
3618    ops: &mut Vec<DiffOp>,
3619) {
3620    use crate::schema::{DeleteAction, DeleteSide};
3621
3622    let existing_fk_names: HashSet<&str> = existing
3623        .map(|e| e.foreign_keys.iter().map(|fk| fk.constraint_name.as_str()).collect())
3624        .unwrap_or_default();
3625
3626    for l in &td.links {
3627        if l.is_junction_backed() {
3628            continue;
3629        }
3630        // Enforced by `export::interface_link_trigger_infos` instead: the
3631        // target is a view, and PostgreSQL will not reference one.
3632        if polymorphic.contains(&l.target) {
3633            continue;
3634        }
3635        let cname = format!("{}_{}_fkey", td.table, l.name);
3636        if existing_fk_names.contains(cname.as_str()) {
3637            continue;
3638        }
3639        let Some((tgt_module, tgt_table)) = type_map.get(&l.target) else {
3640            continue;
3641        };
3642        // See `export::needs_deferred_target_fk`'s doc comment — a
3643        // Source-side DeleteTarget/DeleteTargetIfOrphan trigger deletes the
3644        // target while the still-not-yet-removed owner row would otherwise
3645        // trip an immediate RESTRICT on this very FK (confirmed live via
3646        // `tests/live_execution_on_delete.rs`); force it deferrable so the
3647        // check only runs at commit, after both deletes have completed.
3648        let needs_deferred = crate::export::needs_deferred_target_fk(&l.on_delete);
3649        let on_delete = l
3650            .on_delete
3651            .iter()
3652            .find(|p| p.side == DeleteSide::Target)
3653            .map(|p| match &p.action {
3654                DeleteAction::Restrict if needs_deferred => " DEFERRABLE INITIALLY DEFERRED",
3655                DeleteAction::Restrict => " ON DELETE RESTRICT",
3656                DeleteAction::DeferredRestrict => " DEFERRABLE INITIALLY DEFERRED",
3657                DeleteAction::DeleteSource => " ON DELETE CASCADE",
3658                DeleteAction::Allow => " ON DELETE SET NULL",
3659                _ => " ON DELETE RESTRICT",
3660            })
3661            .unwrap_or(if needs_deferred {
3662                " DEFERRABLE INITIALLY DEFERRED"
3663            } else {
3664                " ON DELETE RESTRICT"
3665            });
3666        push_tx(
3667            ops,
3668            format!(
3669                "ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {}(id){};",
3670                qn(&td.module, &td.table),
3671                qi(&cname),
3672                qi(&format!("{}_id", l.name)),
3673                qn(tgt_module, tgt_table),
3674                on_delete
3675            ),
3676        );
3677    }
3678}
3679
3680// ── Junction table for a new multi-link ───────────────────────────────────────
3681
3682// Every parameter here is one independent axis of a junction table's shape
3683// (name, target, through-type, delete policy, cardinality), and they come
3684// from three different places in the caller. Bundling them into a
3685// params struct would move the same ten fields one level out without making
3686// any call site clearer.
3687#[allow(clippy::too_many_arguments)]
3688fn emit_junction_table(
3689    td: &TypeDescriptor,
3690    ml_name: &str,
3691    through: Option<&str>,
3692    schema: &SchemaDescriptor,
3693    single: bool,
3694    ops: &mut Vec<DiffOp>,
3695) {
3696    let jt_name = format!("{}.{}", td.table, ml_name);
3697    let src_on_delete = " ON DELETE CASCADE";
3698    // The target FK is added by the FK pass below, not inline — the target's
3699    // own table may not exist yet. See `export::junction_fk_constraints`.
3700    let mut col_lines = format!(
3701        "    source uuid NOT NULL REFERENCES {}(id){},\n    target uuid NOT NULL",
3702        qn(&td.module, &td.table),
3703        src_on_delete,
3704    );
3705
3706    // Extra columns from the through junction type.
3707    if let Some(through_qname) = through
3708        && let Some(through_td) = schema
3709            .types
3710            .iter()
3711            .find(|t| format!("{}::{}", t.module, t.name) == through_qname && t.junction)
3712    {
3713        for p in &through_td.properties {
3714            if p.name == "id" {
3715                continue;
3716            }
3717            let not_null = if p.nullable { "" } else { " NOT NULL" };
3718            col_lines.push_str(&format!(",\n    {} {}{}", qi(&p.name), col_type_str(p), not_null));
3719        }
3720    }
3721
3722    let pk_clause = if single {
3723        "PRIMARY KEY (source)"
3724    } else {
3725        "PRIMARY KEY (source, target)"
3726    };
3727    // An exclusive junction's `UNIQUE (target)` is left to the index pass,
3728    // which also adds it to a junction that already exists.
3729    push_tx(
3730        ops,
3731        format!(
3732            "CREATE TABLE IF NOT EXISTS {} (\n{},\n    {}\n);",
3733            qn(&td.module, &jt_name),
3734            col_lines,
3735            pk_clause,
3736        ),
3737    );
3738}
3739
3740// ── diff_states: diff two live-DB snapshots (for squash) ──────────────────────
3741
3742fn diff_states_inner(before: &DbState, after: &DbState) -> Vec<DiffOp> {
3743    let mut ops: Vec<DiffOp> = Vec::new();
3744
3745    let before_schemas: HashSet<&str> = before.schemas.iter().map(|s| s.as_str()).collect();
3746    let before_tables: HashMap<(&str, &str), &DbTable> = before
3747        .tables
3748        .iter()
3749        .map(|t| ((t.schema.as_str(), t.name.as_str()), t))
3750        .collect();
3751    let before_enums: HashMap<(&str, &str), &DbEnum> = before
3752        .enums
3753        .iter()
3754        .map(|e| ((e.schema.as_str(), e.name.as_str()), e))
3755        .collect();
3756    let before_domains: HashSet<(&str, &str)> = before
3757        .domains
3758        .iter()
3759        .map(|d| (d.schema.as_str(), d.name.as_str()))
3760        .collect();
3761
3762    // New schemas
3763    for schema in &after.schemas {
3764        if schema == "default" {
3765            continue;
3766        } // public always exists
3767        if !before_schemas.contains(schema.as_str()) {
3768            push_tx(&mut ops, format!("CREATE SCHEMA IF NOT EXISTS {};", pg_schema(schema)));
3769        }
3770    }
3771
3772    // New / altered enums
3773    for e in &after.enums {
3774        match before_enums.get(&(e.schema.as_str(), e.name.as_str())) {
3775            None => {
3776                let members: Vec<String> = e
3777                    .members
3778                    .iter()
3779                    .map(|m| format!("'{}'", m.replace('\'', "''")))
3780                    .collect();
3781                push_tx(
3782                    &mut ops,
3783                    format!(
3784                        "DO $$ BEGIN CREATE TYPE {}.{} AS ENUM ({}); \
3785                     EXCEPTION WHEN duplicate_object THEN NULL; END $$;",
3786                        pg_schema(&e.schema),
3787                        qi(&e.name),
3788                        members.join(", ")
3789                    ),
3790                );
3791            }
3792            Some(existing) => {
3793                let existing_set: HashSet<&str> = existing.members.iter().map(|m| m.as_str()).collect();
3794                for member in &e.members {
3795                    if !existing_set.contains(member.as_str()) {
3796                        push_tx(
3797                            &mut ops,
3798                            format!(
3799                                "ALTER TYPE {}.{} ADD VALUE IF NOT EXISTS '{}';",
3800                                pg_schema(&e.schema),
3801                                qi(&e.name),
3802                                member.replace('\'', "''")
3803                            ),
3804                        );
3805                    }
3806                }
3807            }
3808        }
3809    }
3810
3811    // New domains (simplified: no type reconstruction from DbState)
3812    for d in &after.domains {
3813        if !before_domains.contains(&(d.schema.as_str(), d.name.as_str())) {
3814            // We can't reconstruct the full domain DDL from pg_catalog cheaply;
3815            // emit a placeholder that will be filled by the squash command.
3816            push_tx(
3817                &mut ops,
3818                format!(
3819                    "-- TODO: recreate domain {}.{} (reconstruct DDL from source migrations)",
3820                    pg_schema(&d.schema),
3821                    qi(&d.name)
3822                ),
3823            );
3824        }
3825    }
3826
3827    // New tables
3828    let mut new_tables: HashSet<(String, String)> = HashSet::new();
3829    for t in &after.tables {
3830        let key = (t.schema.as_str(), t.name.as_str());
3831        match before_tables.get(&key) {
3832            None => {
3833                // Emit CREATE TABLE from introspected columns
3834                emit_create_table_from_db(t, &mut ops);
3835                new_tables.insert((t.schema.clone(), t.name.clone()));
3836            }
3837            Some(before_t) => {
3838                // Diff columns
3839                emit_column_diff_from_db(t, before_t, &mut ops);
3840            }
3841        }
3842    }
3843
3844    // FK constraints for existing tables that gained new FKs
3845    for t in &after.tables {
3846        if let Some(before_t) = before_tables.get(&(t.schema.as_str(), t.name.as_str())) {
3847            let before_fk_names: HashSet<&str> = before_t
3848                .foreign_keys
3849                .iter()
3850                .map(|fk| fk.constraint_name.as_str())
3851                .collect();
3852            for fk in &t.foreign_keys {
3853                if !before_fk_names.contains(fk.constraint_name.as_str()) {
3854                    // Can't fully reconstruct FK DDL from DbForeignKey without ON DELETE info;
3855                    // emit best-effort.
3856                    push_tx(
3857                        &mut ops,
3858                        format!(
3859                            "ALTER TABLE {}.{} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {}.{}(id);",
3860                            pg_schema(&t.schema),
3861                            qi(&t.name),
3862                            qi(&fk.constraint_name),
3863                            qi(&fk.local_column),
3864                            pg_schema(&fk.ref_schema),
3865                            qi(&fk.ref_table)
3866                        ),
3867                    );
3868                }
3869            }
3870        }
3871    }
3872
3873    // New indexes on pre-existing tables (CONCURRENTLY)
3874    for t in &after.tables {
3875        let table_is_new = new_tables.contains(&(t.schema.clone(), t.name.clone()));
3876        let before_indexes = before_tables
3877            .get(&(t.schema.as_str(), t.name.as_str()))
3878            .map(|bt| bt.indexes.as_slice())
3879            .unwrap_or_default();
3880        for idx in &t.indexes {
3881            // Postgres auto-names an index created without a name, so the
3882            // database's name for one never matches the name the schema side
3883            // made up. What settles whether it is already there is the key:
3884            // same columns, same uniqueness. An expression index has no
3885            // column list to compare and falls back to the name.
3886            let already_there = if idx.columns.is_empty() {
3887                before_indexes.iter().any(|b| b.name == idx.name)
3888            } else {
3889                before_indexes
3890                    .iter()
3891                    .any(|b| b.columns == idx.columns && b.is_unique == idx.is_unique)
3892            };
3893            if already_there {
3894                continue;
3895            }
3896            // Vector and search indexes are created by their own passes, which
3897            // know the operator class and expression each needs.
3898            if idx.columns.is_empty() {
3899                continue;
3900            }
3901            let use_concurrently = !table_is_new;
3902            let concurrently = if use_concurrently { "CONCURRENTLY " } else { "" };
3903            let unique = if idx.is_unique { "UNIQUE " } else { "" };
3904            let idx_sql = format!(
3905                "CREATE {unique}INDEX {concurrently}IF NOT EXISTS {} ON {}.{} ({});",
3906                qi(&idx.name),
3907                pg_schema(&t.schema),
3908                qi(&t.name),
3909                idx.columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", "),
3910            );
3911            ops.push(DiffOp {
3912                sql: idx_sql,
3913                non_transactional: use_concurrently,
3914            });
3915        }
3916    }
3917
3918    // Drop removed tables
3919    let after_tables: HashSet<(&str, &str)> = after
3920        .tables
3921        .iter()
3922        .map(|t| (t.schema.as_str(), t.name.as_str()))
3923        .collect();
3924    for t in &before.tables {
3925        if !after_tables.contains(&(t.schema.as_str(), t.name.as_str())) {
3926            push_tx(
3927                &mut ops,
3928                format!("DROP TABLE IF EXISTS {}.{} CASCADE;", pg_schema(&t.schema), qi(&t.name)),
3929            );
3930        }
3931    }
3932
3933    // Drop removed enums
3934    let after_enum_set: HashSet<(&str, &str)> = after
3935        .enums
3936        .iter()
3937        .map(|e| (e.schema.as_str(), e.name.as_str()))
3938        .collect();
3939    for e in &before.enums {
3940        if !after_enum_set.contains(&(e.schema.as_str(), e.name.as_str())) {
3941            push_tx(
3942                &mut ops,
3943                format!("DROP TYPE IF EXISTS {}.{} CASCADE;", pg_schema(&e.schema), qi(&e.name)),
3944            );
3945        }
3946    }
3947
3948    // Drop removed schemas
3949    let after_schema_set: HashSet<&str> = after.schemas.iter().map(|s| s.as_str()).collect();
3950    for schema in &before.schemas {
3951        if schema == "default" {
3952            continue;
3953        } // never drop public
3954        if !after_schema_set.contains(schema.as_str()) {
3955            push_tx(
3956                &mut ops,
3957                format!("DROP SCHEMA IF EXISTS {} CASCADE;", pg_schema(schema)),
3958            );
3959        }
3960    }
3961
3962    ops
3963}
3964
3965fn emit_create_table_from_db(t: &DbTable, ops: &mut Vec<DiffOp>) {
3966    let mut lines: Vec<String> = Vec::new();
3967    for col in &t.columns {
3968        let not_null = if col.nullable { "" } else { " NOT NULL" };
3969        if col.is_generated {
3970            // Can't reconstruct the generation expression from DbColumn alone.
3971            lines.push(format!(
3972                "    {} {} GENERATED ALWAYS AS (/* see source */) STORED",
3973                qi(&col.name),
3974                col.pg_type
3975            ));
3976        } else {
3977            lines.push(format!("    {} {}{}", qi(&col.name), col.pg_type, not_null));
3978        }
3979    }
3980    push_tx(
3981        ops,
3982        format!(
3983            "CREATE TABLE IF NOT EXISTS {}.{} (\n{}\n);",
3984            pg_schema(&t.schema),
3985            qi(&t.name),
3986            lines.join(",\n")
3987        ),
3988    );
3989}
3990
3991fn emit_column_diff_from_db(after: &DbTable, before: &DbTable, ops: &mut Vec<DiffOp>) {
3992    let before_cols: HashSet<&str> = before.columns.iter().map(|c| c.name.as_str()).collect();
3993    let after_cols: HashSet<&str> = after.columns.iter().map(|c| c.name.as_str()).collect();
3994
3995    for col in &after.columns {
3996        if !before_cols.contains(col.name.as_str()) {
3997            let not_null = if col.nullable { "" } else { " NOT NULL" };
3998            push_tx(
3999                ops,
4000                format!(
4001                    "ALTER TABLE {}.{} ADD COLUMN IF NOT EXISTS {} {}{};",
4002                    pg_schema(&after.schema),
4003                    qi(&after.name),
4004                    qi(&col.name),
4005                    col.pg_type,
4006                    not_null
4007                ),
4008            );
4009        }
4010    }
4011    for col in &before.columns {
4012        if !after_cols.contains(col.name.as_str()) {
4013            push_tx(
4014                ops,
4015                format!(
4016                    "ALTER TABLE {}.{} DROP COLUMN IF EXISTS {};",
4017                    pg_schema(&after.schema),
4018                    qi(&after.name),
4019                    qi(&col.name)
4020                ),
4021            );
4022        }
4023    }
4024}
4025
4026// ── Tests ─────────────────────────────────────────────────────────────────────
4027
4028#[cfg(test)]
4029mod tests {
4030    use super::*;
4031    use crate::schema::{EnumDescriptor, LinkDescriptor, PropertyDescriptor, SchemaDescriptor, TypeDescriptor};
4032
4033    fn empty_state() -> DbState {
4034        DbState::default()
4035    }
4036
4037    fn prop(name: &str, pg_type: &str, nullable: bool) -> PropertyDescriptor {
4038        PropertyDescriptor {
4039            name: name.into(),
4040            pg_type: pg_type.into(),
4041            nullable,
4042            default_sql: if name == "id" { Some("uuidv7()".into()) } else { None },
4043            default_pyql: None,
4044            description: None,
4045            check_constraints: vec![],
4046            is_exclusive: name == "id",
4047            is_pk: name == "id",
4048            is_readonly: name == "id",
4049            rewrites: vec![],
4050            tuple_members: None,
4051            column_type: None,
4052        }
4053    }
4054
4055    fn simple_type(module: &str, name: &str, table: &str) -> TypeDescriptor {
4056        TypeDescriptor {
4057            name: name.into(),
4058            module: module.into(),
4059            table: table.into(),
4060            abstract_: false,
4061            materialized: false,
4062            description: None,
4063            parents: vec![],
4064            interfaces: vec![],
4065            bases: vec![],
4066            properties: vec![prop("id", "uuid", false), prop("name", "text", true)],
4067            links: vec![],
4068            multilinks: vec![],
4069            computed: vec![],
4070            constraints: vec![],
4071            indexes: vec![],
4072            partition: None,
4073            vector_indexes: vec![],
4074            search_indexes: vec![],
4075            triggers: vec![],
4076            junction: false,
4077            signals: vec![],
4078        }
4079    }
4080
4081    // ── schema_content_changed ──────────────────────────────────────────────
4082
4083    #[test]
4084    fn test_schema_content_changed_detects_a_readonly_only_flip() {
4085        // The exact previously-broken case: `is_readonly` has zero DDL
4086        // footprint, so `diff_schema_steps`/`diff_schema_ops` alone would
4087        // never notice this change at all.
4088        let before = simple_type("default", "Person", "Person");
4089        let mut after = before.clone();
4090        after.properties[1].is_readonly = true; // "name"
4091        assert_ne!(before.properties[1].is_readonly, after.properties[1].is_readonly);
4092
4093        let schema_before = SchemaDescriptor {
4094            types: vec![before],
4095            scalars: vec![],
4096            enums: vec![],
4097            named_tuples: vec![],
4098            globals: vec![],
4099            functions: vec![],
4100            aliases: vec![],
4101            channels: vec![],
4102            ..Default::default()
4103        };
4104        let schema_after = SchemaDescriptor {
4105            types: vec![after],
4106            scalars: vec![],
4107            enums: vec![],
4108            named_tuples: vec![],
4109            globals: vec![],
4110            functions: vec![],
4111            aliases: vec![],
4112            channels: vec![],
4113            ..Default::default()
4114        };
4115        assert!(schema_content_changed(&schema_after, Some(&schema_before)));
4116    }
4117
4118    #[test]
4119    fn test_schema_content_changed_detects_a_new_rewrite() {
4120        let before = simple_type("default", "Person", "Person");
4121        let mut after = before.clone();
4122        after.properties[1].rewrites.push(crate::schema::RewriteEntry {
4123            on: 1,
4124            handler: "str_upper(.name)".into(),
4125        });
4126
4127        let schema_before = SchemaDescriptor {
4128            types: vec![before],
4129            scalars: vec![],
4130            enums: vec![],
4131            named_tuples: vec![],
4132            globals: vec![],
4133            functions: vec![],
4134            aliases: vec![],
4135            channels: vec![],
4136            ..Default::default()
4137        };
4138        let schema_after = SchemaDescriptor {
4139            types: vec![after],
4140            scalars: vec![],
4141            enums: vec![],
4142            named_tuples: vec![],
4143            globals: vec![],
4144            functions: vec![],
4145            aliases: vec![],
4146            channels: vec![],
4147            ..Default::default()
4148        };
4149        assert!(schema_content_changed(&schema_after, Some(&schema_before)));
4150    }
4151
4152    #[test]
4153    fn test_schema_content_changed_is_false_for_identical_schemas() {
4154        let t = simple_type("default", "Person", "Person");
4155        let schema = SchemaDescriptor {
4156            types: vec![t],
4157            scalars: vec![],
4158            enums: vec![],
4159            named_tuples: vec![],
4160            globals: vec![],
4161            functions: vec![],
4162            aliases: vec![],
4163            channels: vec![],
4164            ..Default::default()
4165        };
4166        let other = schema.clone();
4167        assert!(!schema_content_changed(&schema, Some(&other)));
4168    }
4169
4170    #[test]
4171    fn test_schema_content_changed_true_against_none_when_target_is_non_empty() {
4172        let t = simple_type("default", "Person", "Person");
4173        let schema = SchemaDescriptor {
4174            types: vec![t],
4175            scalars: vec![],
4176            enums: vec![],
4177            named_tuples: vec![],
4178            globals: vec![],
4179            functions: vec![],
4180            aliases: vec![],
4181            channels: vec![],
4182            ..Default::default()
4183        };
4184        assert!(
4185            schema_content_changed(&schema, None),
4186            "no prior snapshot at all must count as changed"
4187        );
4188    }
4189
4190    #[test]
4191    fn test_schema_content_changed_false_against_none_when_target_is_also_empty() {
4192        let schema = SchemaDescriptor::default();
4193        assert!(!schema_content_changed(&schema, None));
4194    }
4195
4196    #[test]
4197    fn test_schema_content_changed_still_true_when_ddl_visible_things_also_changed() {
4198        // A DDL-visible change (a whole new type) must also register —
4199        // this function is meant to be OR'd with diff_schema_steps's own
4200        // result, not treated as mutually exclusive with it.
4201        let schema_before = SchemaDescriptor::default();
4202        let schema_after = SchemaDescriptor {
4203            types: vec![simple_type("default", "Person", "Person")],
4204            scalars: vec![],
4205            enums: vec![],
4206            named_tuples: vec![],
4207            globals: vec![],
4208            functions: vec![],
4209            aliases: vec![],
4210            channels: vec![],
4211            ..Default::default()
4212        };
4213        assert!(schema_content_changed(&schema_after, Some(&schema_before)));
4214    }
4215
4216    #[test]
4217    fn test_schema_content_changed_detects_a_new_channel() {
4218        // A `Channel` has zero physical DDL footprint — nothing about it
4219        // ever shows up in `diff_schema_steps`/`diff_schema_ops`, so its
4220        // mere addition is exactly the class of change this function
4221        // exists to catch, same as the readonly/rewrite cases above.
4222        let schema_before = SchemaDescriptor::default();
4223        let schema_after = SchemaDescriptor {
4224            channels: vec![crate::schema::ChannelDescriptor {
4225                name: "UserUpdates".into(),
4226                module: "default".into(),
4227                wire_name: "default__user_updates".into(),
4228                payload: crate::schema::ChannelPayload::Scalar("text".into()),
4229                description: None,
4230            }],
4231            ..Default::default()
4232        };
4233        assert!(schema_content_changed(&schema_after, Some(&schema_before)));
4234    }
4235
4236    #[test]
4237    fn test_new_schema_and_table() {
4238        let schema = SchemaDescriptor {
4239            types: vec![simple_type("catalog", "Product", "Product")],
4240            scalars: vec![],
4241            enums: vec![],
4242            named_tuples: vec![],
4243            globals: vec![],
4244            functions: vec![],
4245            aliases: vec![],
4246            channels: vec![],
4247            ..Default::default()
4248        };
4249        let ops = diff_schema(&schema, &empty_state()).unwrap();
4250        let joined = ops.join("\n");
4251        assert!(
4252            joined.contains("CREATE SCHEMA IF NOT EXISTS \"catalog\""),
4253            "got:\n{joined}"
4254        );
4255        assert!(
4256            joined.contains("CREATE TABLE IF NOT EXISTS \"catalog\".\"Product\""),
4257            "got:\n{joined}"
4258        );
4259        assert!(
4260            joined.contains("CREATE OR REPLACE TRIGGER pylon_cache_invalidate\n    AFTER INSERT OR UPDATE OR DELETE ON \"catalog\".\"Product\""),
4261            "new table must get the cache-invalidation trigger; got:\n{joined}"
4262        );
4263    }
4264
4265    fn widget_with_trigger(on: u8, timing: &str, handler: &str) -> TypeDescriptor {
4266        let mut t = simple_type("default", "Widget", "Widget");
4267        t.triggers = vec![crate::schema::TriggerDescriptor {
4268            on,
4269            timing: timing.into(),
4270            handler: handler.into(),
4271        }];
4272        t
4273    }
4274
4275    #[test]
4276    fn test_new_table_with_user_trigger_emits_the_compiled_trigger_ddl() {
4277        // Regression guard for the previously-missing incremental-migration
4278        // path: `export_schema` always emitted a schema's user-declared
4279        // `Trigger`s, but `diff_schema`/`diff_schema_steps` (the path
4280        // `migration create` actually uses) never did, so adding a
4281        // `Trigger(...)` to an existing type had zero effect on a real
4282        // migration.
4283        let schema = SchemaDescriptor {
4284            types: vec![widget_with_trigger(
4285                1,
4286                "After",
4287                "update Widget set { name := __new__.name }",
4288            )],
4289            scalars: vec![],
4290            enums: vec![],
4291            named_tuples: vec![],
4292            globals: vec![],
4293            functions: vec![],
4294            aliases: vec![],
4295            channels: vec![],
4296            ..Default::default()
4297        };
4298        let ops = diff_schema(&schema, &empty_state()).unwrap();
4299        let joined = ops.join("\n");
4300        assert!(joined.contains("NEW.\"name\""), "got:\n{joined}");
4301    }
4302
4303    #[test]
4304    fn a_trigger_emitted_by_an_older_build_is_replaced() {
4305        // The delivery half of content-addressed trigger names: because a
4306        // trigger's name now hashes its body, a database whose trigger was
4307        // written by an older emitter carries a *different* name than the
4308        // current one, and that difference is what the diff can see. Before
4309        // this, the name was derived from the table and pointer alone, so a
4310        // fix to trigger codegen produced no diff at all and the old body
4311        // stayed until the table was dropped.
4312        //
4313        // Simulated by renaming the trigger in the baseline, which is
4314        // exactly what an emitter change looks like from the diff's side.
4315        let schema = SchemaDescriptor {
4316            types: vec![widget_with_trigger(
4317                1,
4318                "After",
4319                "update Widget set { name := __new__.name }",
4320            )],
4321            scalars: vec![],
4322            enums: vec![],
4323            named_tuples: vec![],
4324            globals: vec![],
4325            functions: vec![],
4326            aliases: vec![],
4327            channels: vec![],
4328            ..Default::default()
4329        };
4330        let mut stale = schema_to_db_state(&schema);
4331        let current_name = stale
4332            .tables
4333            .iter()
4334            .flat_map(|t| t.triggers.iter().cloned())
4335            .find(|n| n.starts_with("Widget_"))
4336            .expect("the fixture should project a Widget trigger");
4337        let stale_name = "Widget_trg_0badc0de".to_string();
4338        for table in &mut stale.tables {
4339            for trigger in &mut table.triggers {
4340                if *trigger == current_name {
4341                    *trigger = stale_name.clone();
4342                }
4343            }
4344        }
4345
4346        let joined = diff_schema(&schema, &stale).unwrap().join("\n");
4347        assert!(
4348            joined.contains(&format!("DROP TRIGGER IF EXISTS \"{stale_name}\"")),
4349            "the stale trigger should be dropped, got:\n{joined}"
4350        );
4351        assert!(
4352            joined.contains(&current_name),
4353            "the current trigger should be created, got:\n{joined}"
4354        );
4355        // The stale trigger's function goes with it — a rename would
4356        // otherwise leave a dead function behind on every codegen change.
4357        assert!(
4358            joined.contains(&format!("DROP FUNCTION IF EXISTS \"public\".\"{stale_name}\"()")),
4359            "the orphaned function should be dropped, got:\n{joined}"
4360        );
4361    }
4362
4363    #[test]
4364    fn test_user_trigger_already_present_in_offline_baseline_produces_no_further_steps() {
4365        // Same "phantom step" bug class fixed earlier for cache-invalidate/
4366        // signal triggers: `schema_to_db_state`'s projection must recognize
4367        // a user Trigger as already present, or `migration create` would
4368        // propose recreating it forever, even with zero real schema changes.
4369        let schema = SchemaDescriptor {
4370            types: vec![widget_with_trigger(
4371                1,
4372                "After",
4373                "update Widget set { name := __new__.name }",
4374            )],
4375            scalars: vec![],
4376            enums: vec![],
4377            named_tuples: vec![],
4378            globals: vec![],
4379            functions: vec![],
4380            aliases: vec![],
4381            channels: vec![],
4382            ..Default::default()
4383        };
4384        let baseline = schema_to_db_state(&schema);
4385        let steps = diff_schema_steps(&schema, &baseline, &HashMap::new()).unwrap();
4386        assert!(
4387            steps.is_empty(),
4388            "expected zero further migration steps, got: {steps:?}"
4389        );
4390    }
4391
4392    #[test]
4393    fn a_link_target_gaining_a_subtype_loses_its_foreign_key() {
4394        let mut order = simple_type("default", "Order", "Order");
4395        order.links.push(LinkDescriptor {
4396            name: "customer".into(),
4397            target: "default::Person".into(),
4398            nullable: false,
4399            through: None,
4400            description: None,
4401            default_pyql: None,
4402            is_exclusive: false,
4403            is_readonly: false,
4404            rewrites: vec![],
4405            on_delete: vec![],
4406        });
4407        let before = SchemaDescriptor {
4408            types: vec![order, simple_type("default", "Person", "Person")],
4409            scalars: vec![],
4410            enums: vec![],
4411            named_tuples: vec![],
4412            globals: vec![],
4413            functions: vec![],
4414            aliases: vec![],
4415            channels: vec![],
4416            ..Default::default()
4417        };
4418        let mut after = before.clone();
4419        let mut vip = simple_type("default", "Vip", "Vip");
4420        vip.bases = vec!["default::Person".into()];
4421        after.types.push(vip);
4422
4423        let joined = diff_schema(&after, &schema_to_db_state(&before)).unwrap().join("\n");
4424        assert!(
4425            joined.contains("ALTER TABLE \"public\".\"Order\" DROP CONSTRAINT IF EXISTS \"Order_customer_fkey\""),
4426            "got:\n{joined}"
4427        );
4428        let settled = diff_schema_steps(&after, &schema_to_db_state(&after), &HashMap::new()).unwrap();
4429        assert!(settled.is_empty(), "nothing further once applied, got: {settled:?}");
4430    }
4431
4432    #[test]
4433    fn test_new_table_with_plain_link_gets_its_fk_constraint() {
4434        // Regression guard: a brand-new table's `emit_create_table` only ever
4435        // emits a plain `uuid` column for a single link — the FK constraint
4436        // itself comes from Phase 6 (`emit_fk_diff`), which used to run only
4437        // for tables already present in `cur_tables`, silently skipping every
4438        // link on a table created in the same diff pass.
4439        let mut order = simple_type("default", "Order", "Order");
4440        order.links.push(LinkDescriptor {
4441            name: "customer".into(),
4442            target: "default::Person".into(),
4443            nullable: false,
4444            through: None,
4445            description: None,
4446            default_pyql: None,
4447            is_exclusive: false,
4448            is_readonly: false,
4449            rewrites: vec![],
4450            on_delete: vec![],
4451        });
4452        let schema = SchemaDescriptor {
4453            types: vec![order, simple_type("default", "Person", "Person")],
4454            scalars: vec![],
4455            enums: vec![],
4456            named_tuples: vec![],
4457            globals: vec![],
4458            functions: vec![],
4459            aliases: vec![],
4460            channels: vec![],
4461            ..Default::default()
4462        };
4463        let ops = diff_schema(&schema, &empty_state()).unwrap();
4464        let joined = ops.join("\n");
4465        assert!(
4466            joined.contains("ADD CONSTRAINT \"Order_customer_fkey\" FOREIGN KEY (\"customer_id\") REFERENCES \"public\".\"Person\"(id)"),
4467            "new table's plain link must get its FK constraint in the same diff; got:\n{joined}"
4468        );
4469    }
4470
4471    #[test]
4472    fn test_an_existing_exclusive_multilink_junction_gains_its_unique_target() {
4473        use crate::schema::MultiLinkDescriptor;
4474
4475        let mut person = simple_type("default", "Person", "Person");
4476        person.multilinks.push(MultiLinkDescriptor {
4477            name: "keys".into(),
4478            target: "default::Key".into(),
4479            through: None,
4480            nullable: false,
4481            description: None,
4482            default_pyql: None,
4483            on_delete: vec![],
4484            is_exclusive: true,
4485        });
4486        let schema = SchemaDescriptor {
4487            types: vec![person, simple_type("default", "Key", "Key")],
4488            scalars: vec![],
4489            enums: vec![],
4490            named_tuples: vec![],
4491            globals: vec![],
4492            functions: vec![],
4493            aliases: vec![],
4494            channels: vec![],
4495            ..Default::default()
4496        };
4497        let mut state = schema_to_db_state(&schema);
4498        assert!(diff_schema(&schema, &state).unwrap().is_empty());
4499
4500        for table in state.tables.iter_mut().filter(|t| t.name == "Person.keys") {
4501            table.indexes.clear();
4502        }
4503        let joined = diff_schema(&schema, &state).unwrap().join("\n");
4504        assert!(
4505            joined.contains("CREATE UNIQUE INDEX IF NOT EXISTS \"Person.keys_target_key\" ON \"public\".\"Person.keys\" (\"target\")"),
4506            "got:\n{joined}"
4507        );
4508    }
4509
4510    #[test]
4511    fn test_cache_invalidate_trigger_not_duplicated_for_junction_through_type() {
4512        use crate::schema::MultiLinkDescriptor;
4513
4514        let mut product = simple_type("default", "Product", "Product");
4515        product.multilinks.push(MultiLinkDescriptor {
4516            name: "tags".into(),
4517            target: "default::Tag".into(),
4518            through: Some("default::ProductTag".into()),
4519            nullable: false,
4520            description: None,
4521            default_pyql: None,
4522            on_delete: vec![],
4523            is_exclusive: false,
4524        });
4525        let mut junction = simple_type("default", "ProductTag", "Product.tags");
4526        junction.junction = true;
4527
4528        let schema = SchemaDescriptor {
4529            types: vec![product, junction, simple_type("default", "Tag", "Tag")],
4530            scalars: vec![],
4531            enums: vec![],
4532            named_tuples: vec![],
4533            globals: vec![],
4534            functions: vec![],
4535            aliases: vec![],
4536            channels: vec![],
4537            ..Default::default()
4538        };
4539        let ops = diff_schema(&schema, &empty_state()).unwrap();
4540        let trigger_count = ops
4541            .iter()
4542            .filter(|op| op.contains("AFTER INSERT OR UPDATE OR DELETE ON \"public\".\"Product.tags\""))
4543            .count();
4544        assert_eq!(
4545            trigger_count, 1,
4546            "junction table's own td.table and the owning type's multilink both resolve to \
4547             the same physical table — must be deduped to one trigger, got {trigger_count} in: {ops:?}"
4548        );
4549    }
4550
4551    fn person_with_junction_backed_spouse() -> SchemaDescriptor {
4552        let mut person = simple_type("default", "Person", "Person");
4553        person.links.push(LinkDescriptor {
4554            name: "spouse".into(),
4555            target: "default::Person".into(),
4556            nullable: true,
4557            through: Some("default::Marriage".into()),
4558            description: None,
4559            default_pyql: None,
4560            is_exclusive: true,
4561            is_readonly: false,
4562            rewrites: vec![],
4563            on_delete: vec![],
4564        });
4565        let mut junction = simple_type("default", "Marriage", "Person.spouse");
4566        junction.junction = true;
4567
4568        SchemaDescriptor {
4569            types: vec![person, junction],
4570            scalars: vec![],
4571            enums: vec![],
4572            named_tuples: vec![],
4573            globals: vec![],
4574            functions: vec![],
4575            aliases: vec![],
4576            channels: vec![],
4577            ..Default::default()
4578        }
4579    }
4580
4581    #[test]
4582    fn test_junction_backed_single_link_creates_junction_table_from_scratch() {
4583        let schema = person_with_junction_backed_spouse();
4584        let ops = diff_schema(&schema, &empty_state()).unwrap();
4585        let joined = ops.join("\n");
4586        assert!(
4587            !joined.contains("spouse_id"),
4588            "no {{name}}_id column/FK for a junction-backed link, got:\n{joined}"
4589        );
4590        assert!(
4591            joined.contains("CREATE TABLE IF NOT EXISTS \"public\".\"Person.spouse\""),
4592            "got:\n{joined}"
4593        );
4594        assert!(joined.contains("PRIMARY KEY (source)"), "got:\n{joined}");
4595        assert!(
4596            joined.contains("CREATE UNIQUE INDEX IF NOT EXISTS \"Person.spouse_target_key\" ON \"public\".\"Person.spouse\" (\"target\")"),
4597            "got:\n{joined}"
4598        );
4599    }
4600
4601    #[test]
4602    fn test_junction_backed_single_link_diff_is_idempotent_once_applied() {
4603        // Regression guard for the drop-detection gap called out in the
4604        // junction-backed-single-link plan: without including link-through
4605        // junction tables in the create-loop/drop-detection/cache-trigger
4606        // sets, the table would get created then immediately flagged for
4607        // drop as "unknown" on the very next diff. This models the DbState
4608        // a live Postgres introspection would report right after the
4609        // create-from-scratch ops above were actually applied.
4610        let schema = person_with_junction_backed_spouse();
4611        let state = DbState {
4612            schemas: vec![],
4613            tables: vec![
4614                DbTable {
4615                    schema: "default".into(),
4616                    name: "Person".into(),
4617                    columns: vec![
4618                        DbColumn {
4619                            name: "id".into(),
4620                            pg_type: "uuid".into(),
4621                            nullable: false,
4622                            is_generated: false,
4623                            column_default: Some("uuidv7()".into()),
4624                        },
4625                        DbColumn {
4626                            name: "name".into(),
4627                            pg_type: "text".into(),
4628                            nullable: true,
4629                            is_generated: false,
4630                            column_default: None,
4631                        },
4632                    ],
4633                    foreign_keys: vec![],
4634                    indexes: vec![],
4635                    checks: vec![],
4636                    triggers: vec!["pylon_cache_invalidate".into()],
4637                },
4638                DbTable {
4639                    schema: "default".into(),
4640                    name: "Person.spouse".into(),
4641                    columns: vec![
4642                        DbColumn {
4643                            name: "source".into(),
4644                            pg_type: "uuid".into(),
4645                            nullable: false,
4646                            is_generated: false,
4647                            column_default: None,
4648                        },
4649                        DbColumn {
4650                            name: "target".into(),
4651                            pg_type: "uuid".into(),
4652                            nullable: false,
4653                            is_generated: false,
4654                            column_default: None,
4655                        },
4656                        DbColumn {
4657                            name: "name".into(),
4658                            pg_type: "text".into(),
4659                            nullable: true,
4660                            is_generated: false,
4661                            column_default: None,
4662                        },
4663                    ],
4664                    foreign_keys: vec![
4665                        DbForeignKey {
4666                            constraint_name: "Person_spouse_source_fkey".into(),
4667                            local_column: "source".into(),
4668                            ref_schema: "default".into(),
4669                            ref_table: "Person".into(),
4670                        },
4671                        DbForeignKey {
4672                            constraint_name: "Person_spouse_target_fkey".into(),
4673                            local_column: "target".into(),
4674                            ref_schema: "default".into(),
4675                            ref_table: "Person".into(),
4676                        },
4677                    ],
4678                    indexes: vec![DbIndex {
4679                        name: "Person.spouse_target_key".into(),
4680                        is_unique: true,
4681                        method: "btree".into(),
4682                        columns: vec!["target".into()],
4683                        predicate: None,
4684                        key: None,
4685                    }],
4686                    checks: vec![],
4687                    triggers: vec!["pylon_cache_invalidate".into()],
4688                },
4689            ],
4690            enums: vec![],
4691            domains: vec![],
4692            ..DbState::default()
4693        };
4694        let ops = diff_schema(&schema, &state).unwrap();
4695        assert!(
4696            ops.is_empty(),
4697            "already-migrated junction-backed single link must diff to no ops, got: {:?}",
4698            ops
4699        );
4700    }
4701
4702    #[test]
4703    fn test_cache_invalidate_trigger_backfilled_on_pre_existing_table() {
4704        let schema = SchemaDescriptor {
4705            types: vec![simple_type("default", "Person", "Person")],
4706            scalars: vec![],
4707            enums: vec![],
4708            named_tuples: vec![],
4709            globals: vec![],
4710            functions: vec![],
4711            aliases: vec![],
4712            channels: vec![],
4713            ..Default::default()
4714        };
4715        let state = DbState {
4716            schemas: vec!["default".into()],
4717            tables: vec![DbTable {
4718                schema: "default".into(),
4719                name: "Person".into(),
4720                columns: vec![
4721                    DbColumn {
4722                        name: "id".into(),
4723                        pg_type: "uuid".into(),
4724                        nullable: false,
4725                        is_generated: false,
4726                        column_default: Some("uuidv7()".into()),
4727                    },
4728                    DbColumn {
4729                        name: "name".into(),
4730                        pg_type: "text".into(),
4731                        nullable: true,
4732                        is_generated: false,
4733                        column_default: None,
4734                    },
4735                ],
4736                foreign_keys: vec![],
4737                indexes: vec![],
4738                checks: vec![],
4739                triggers: vec![], // pre-existing table, created before this feature shipped
4740            }],
4741            enums: vec![],
4742            domains: vec![],
4743            ..DbState::default()
4744        };
4745        let ops = diff_schema(&schema, &state).unwrap();
4746        let joined = ops.join("\n");
4747        assert!(
4748            joined.contains("CREATE OR REPLACE TRIGGER pylon_cache_invalidate\n    AFTER INSERT OR UPDATE OR DELETE ON \"public\".\"Person\""),
4749            "pre-existing table missing the trigger must get it backfilled; got:\n{joined}"
4750        );
4751    }
4752
4753    #[test]
4754    fn test_cache_invalidate_trigger_not_dropped_when_already_present() {
4755        let schema = SchemaDescriptor {
4756            types: vec![simple_type("default", "Person", "Person")],
4757            scalars: vec![],
4758            enums: vec![],
4759            named_tuples: vec![],
4760            globals: vec![],
4761            functions: vec![],
4762            aliases: vec![],
4763            channels: vec![],
4764            ..Default::default()
4765        };
4766        let state = DbState {
4767            schemas: vec!["default".into()],
4768            tables: vec![DbTable {
4769                schema: "default".into(),
4770                name: "Person".into(),
4771                columns: vec![
4772                    DbColumn {
4773                        name: "id".into(),
4774                        pg_type: "uuid".into(),
4775                        nullable: false,
4776                        is_generated: false,
4777                        column_default: Some("uuidv7()".into()),
4778                    },
4779                    DbColumn {
4780                        name: "name".into(),
4781                        pg_type: "text".into(),
4782                        nullable: true,
4783                        is_generated: false,
4784                        column_default: None,
4785                    },
4786                ],
4787                foreign_keys: vec![],
4788                indexes: vec![],
4789                checks: vec![],
4790                triggers: vec!["pylon_cache_invalidate".into()],
4791            }],
4792            enums: vec![],
4793            domains: vec![],
4794            ..DbState::default()
4795        };
4796        let ops = diff_schema(&schema, &state).unwrap();
4797        assert!(
4798            ops.iter()
4799                .all(|op| !op.contains("DROP TRIGGER") && !op.contains("pylon_cache_invalidate")),
4800            "already-present trigger must not be re-created or dropped; got: {:?}",
4801            ops
4802        );
4803    }
4804
4805    #[test]
4806    fn test_no_ops_when_in_sync() {
4807        let schema = SchemaDescriptor {
4808            types: vec![simple_type("default", "Person", "Person")],
4809            scalars: vec![],
4810            enums: vec![],
4811            named_tuples: vec![],
4812            globals: vec![],
4813            functions: vec![],
4814            aliases: vec![],
4815            channels: vec![],
4816            ..Default::default()
4817        };
4818        let state = DbState {
4819            schemas: vec!["default".into()],
4820            tables: vec![DbTable {
4821                schema: "default".into(),
4822                name: "Person".into(),
4823                columns: vec![
4824                    DbColumn {
4825                        name: "id".into(),
4826                        pg_type: "uuid".into(),
4827                        nullable: false,
4828                        is_generated: false,
4829                        column_default: Some("uuidv7()".into()),
4830                    },
4831                    DbColumn {
4832                        name: "name".into(),
4833                        pg_type: "text".into(),
4834                        nullable: true,
4835                        is_generated: false,
4836                        column_default: None,
4837                    },
4838                ],
4839                foreign_keys: vec![],
4840                indexes: vec![],
4841                checks: vec![],
4842                triggers: vec!["pylon_cache_invalidate".into()],
4843            }],
4844            enums: vec![],
4845            domains: vec![],
4846            ..DbState::default()
4847        };
4848        let ops = diff_schema(&schema, &state).unwrap();
4849        assert!(ops.is_empty(), "expected no ops, got: {:?}", ops);
4850    }
4851
4852    #[test]
4853    fn test_add_column() {
4854        let mut td = simple_type("default", "Person", "Person");
4855        td.properties.push(prop("email", "text", true));
4856        let schema = SchemaDescriptor {
4857            types: vec![td],
4858            scalars: vec![],
4859            enums: vec![],
4860            named_tuples: vec![],
4861            globals: vec![],
4862            functions: vec![],
4863            aliases: vec![],
4864            channels: vec![],
4865            ..Default::default()
4866        };
4867        let state = DbState {
4868            schemas: vec!["default".into()],
4869            tables: vec![DbTable {
4870                schema: "default".into(),
4871                name: "Person".into(),
4872                columns: vec![
4873                    DbColumn {
4874                        name: "id".into(),
4875                        pg_type: "uuid".into(),
4876                        nullable: false,
4877                        is_generated: false,
4878                        column_default: Some("uuidv7()".into()),
4879                    },
4880                    DbColumn {
4881                        name: "name".into(),
4882                        pg_type: "text".into(),
4883                        nullable: true,
4884                        is_generated: false,
4885                        column_default: None,
4886                    },
4887                ],
4888                foreign_keys: vec![],
4889                indexes: vec![],
4890                checks: vec![],
4891                triggers: vec![],
4892            }],
4893            enums: vec![],
4894            domains: vec![],
4895            ..DbState::default()
4896        };
4897        let ops = diff_schema(&schema, &state).unwrap();
4898        let joined = ops.join("\n");
4899        assert!(joined.contains("ADD COLUMN IF NOT EXISTS \"email\""), "got:\n{joined}");
4900    }
4901
4902    #[test]
4903    fn test_property_type_change_emits_alter_column_type() {
4904        // A genuine type change (text -> int8) on an existing column must be
4905        // migrated, not silently left stale.
4906        let mut td = simple_type("default", "Person", "Person");
4907        td.properties.push(prop("rating", "int8", true));
4908        let schema = SchemaDescriptor {
4909            types: vec![td],
4910            scalars: vec![],
4911            enums: vec![],
4912            named_tuples: vec![],
4913            globals: vec![],
4914            functions: vec![],
4915            aliases: vec![],
4916            channels: vec![],
4917            ..Default::default()
4918        };
4919        let state = DbState {
4920            schemas: vec!["default".into()],
4921            tables: vec![DbTable {
4922                schema: "default".into(),
4923                name: "Person".into(),
4924                columns: vec![
4925                    DbColumn {
4926                        name: "id".into(),
4927                        pg_type: "uuid".into(),
4928                        nullable: false,
4929                        is_generated: false,
4930                        column_default: Some("uuidv7()".into()),
4931                    },
4932                    DbColumn {
4933                        name: "name".into(),
4934                        pg_type: "text".into(),
4935                        nullable: true,
4936                        is_generated: false,
4937                        column_default: None,
4938                    },
4939                    DbColumn {
4940                        name: "rating".into(),
4941                        pg_type: "text".into(),
4942                        nullable: true,
4943                        is_generated: false,
4944                        column_default: None,
4945                    },
4946                ],
4947                foreign_keys: vec![],
4948                indexes: vec![],
4949                checks: vec![],
4950                triggers: vec![],
4951            }],
4952            enums: vec![],
4953            domains: vec![],
4954            ..DbState::default()
4955        };
4956        let ops = diff_schema(&schema, &state).unwrap();
4957        let joined = ops.join("\n");
4958        assert!(
4959            joined.contains(
4960                "ALTER TABLE \"public\".\"Person\" ALTER COLUMN \"rating\" TYPE int8 USING \"rating\"::int8;"
4961            ),
4962            "got:\n{joined}"
4963        );
4964    }
4965
4966    #[test]
4967    fn test_a_column_moving_between_enums_converts_through_text() {
4968        // PostgreSQL has no enum-to-enum cast, so the direct one the diff used
4969        // to emit aborted the migration outright: `cannot cast type "Method"[]
4970        // to "HttpMethod"[]`. Renaming an enum is the ordinary way to reach
4971        // this.
4972        let mut td = simple_type("default", "Person", "Person");
4973        td.properties.push(prop("verb", "\"public\".\"HttpMethod\"", true));
4974        let schema = SchemaDescriptor {
4975            types: vec![td],
4976            enums: vec![crate::schema::EnumDescriptor {
4977                name: "HttpMethod".into(),
4978                module: "default".into(),
4979                members: vec!["GET".into(), "POST".into()],
4980            }],
4981            ..Default::default()
4982        };
4983        let state = DbState {
4984            schemas: vec!["default".into()],
4985            tables: vec![DbTable {
4986                schema: "default".into(),
4987                name: "Person".into(),
4988                columns: vec![
4989                    DbColumn {
4990                        name: "id".into(),
4991                        pg_type: "uuid".into(),
4992                        nullable: false,
4993                        is_generated: false,
4994                        column_default: Some("uuidv7()".into()),
4995                    },
4996                    DbColumn {
4997                        name: "verb".into(),
4998                        pg_type: "\"public\".\"Method\"".into(),
4999                        nullable: true,
5000                        is_generated: false,
5001                        column_default: None,
5002                    },
5003                ],
5004                foreign_keys: vec![],
5005                indexes: vec![],
5006                checks: vec![],
5007                triggers: vec![],
5008            }],
5009            enums: vec![],
5010            domains: vec![],
5011            ..DbState::default()
5012        };
5013        let joined = diff_schema(&schema, &state).unwrap().join("\n");
5014        assert!(
5015            joined.contains(r#"USING "verb"::text::"public"."HttpMethod";"#),
5016            "the conversion should go through text:\n{joined}"
5017        );
5018    }
5019
5020    #[test]
5021    fn test_a_column_default_is_dropped_before_its_type_changes() {
5022        // Postgres checks the existing DEFAULT against the new type before any
5023        // later `SET DEFAULT` can replace it: "default for column ... cannot
5024        // be cast automatically". The drop has to come first.
5025        let mut td = simple_type("default", "Person", "Person");
5026        let mut rating = prop("rating", "int8", true);
5027        rating.default_sql = Some("'1'".into());
5028        td.properties.push(rating);
5029        let schema = SchemaDescriptor {
5030            types: vec![td],
5031            ..Default::default()
5032        };
5033        let state = DbState {
5034            schemas: vec!["default".into()],
5035            tables: vec![DbTable {
5036                schema: "default".into(),
5037                name: "Person".into(),
5038                columns: vec![
5039                    DbColumn {
5040                        name: "id".into(),
5041                        pg_type: "uuid".into(),
5042                        nullable: false,
5043                        is_generated: false,
5044                        column_default: Some("uuidv7()".into()),
5045                    },
5046                    DbColumn {
5047                        name: "rating".into(),
5048                        pg_type: "text".into(),
5049                        nullable: true,
5050                        is_generated: false,
5051                        column_default: Some("'x'::text".into()),
5052                    },
5053                ],
5054                foreign_keys: vec![],
5055                indexes: vec![],
5056                checks: vec![],
5057                triggers: vec![],
5058            }],
5059            enums: vec![],
5060            domains: vec![],
5061            ..DbState::default()
5062        };
5063        let joined = diff_schema(&schema, &state).unwrap().join("\n");
5064        let Some(drop) = joined.find(r#"ALTER COLUMN "rating" DROP DEFAULT;"#) else {
5065            panic!("the default should be dropped:\n{joined}");
5066        };
5067        let Some(retype) = joined.find(r#"ALTER COLUMN "rating" TYPE int8"#) else {
5068            panic!("the type should change:\n{joined}");
5069        };
5070        assert!(drop < retype, "the drop must come first:\n{joined}");
5071    }
5072
5073    #[test]
5074    fn test_property_type_change_surfaces_a_required_cast_expression_step() {
5075        let mut td = simple_type("default", "Person", "Person");
5076        td.properties.push(prop("rating", "int8", true));
5077        let schema = SchemaDescriptor {
5078            types: vec![td],
5079            scalars: vec![],
5080            enums: vec![],
5081            named_tuples: vec![],
5082            globals: vec![],
5083            functions: vec![],
5084            aliases: vec![],
5085            channels: vec![],
5086            ..Default::default()
5087        };
5088        let state = DbState {
5089            schemas: vec!["default".into()],
5090            tables: vec![DbTable {
5091                schema: "default".into(),
5092                name: "Person".into(),
5093                columns: vec![
5094                    DbColumn {
5095                        name: "id".into(),
5096                        pg_type: "uuid".into(),
5097                        nullable: false,
5098                        is_generated: false,
5099                        column_default: Some("uuidv7()".into()),
5100                    },
5101                    DbColumn {
5102                        name: "name".into(),
5103                        pg_type: "text".into(),
5104                        nullable: true,
5105                        is_generated: false,
5106                        column_default: None,
5107                    },
5108                    DbColumn {
5109                        name: "rating".into(),
5110                        pg_type: "text".into(),
5111                        nullable: true,
5112                        is_generated: false,
5113                        column_default: None,
5114                    },
5115                ],
5116                foreign_keys: vec![],
5117                indexes: vec![],
5118                checks: vec![],
5119                triggers: vec![],
5120            }],
5121            enums: vec![],
5122            domains: vec![],
5123            ..DbState::default()
5124        };
5125
5126        let steps = diff_schema_steps(&schema, &state, &HashMap::new()).unwrap();
5127        let step = steps
5128            .iter()
5129            .find(|s| matches!(&s.op_key, OpKey::Table(m, t) if m == "default" && t == "Person"))
5130            .expect("expected an alter step for Person");
5131
5132        assert_eq!(step.required_input.len(), 1, "got: {:?}", step.required_input);
5133        let input = &step.required_input[0];
5134        assert_eq!(input.placeholder, "cast_expr__rating");
5135        assert_eq!(input.default_expr, "\"rating\"::int8");
5136        assert_eq!(input.type_name, "default::Person");
5137
5138        let placeholder_token = format!("\\({})", input.placeholder);
5139        assert!(
5140            step.ddl.iter().any(|op| op.sql.contains(&placeholder_token)),
5141            "expected the placeholder token in the step's DDL, got: {:?}",
5142            step.ddl.iter().map(|op| &op.sql).collect::<Vec<_>>()
5143        );
5144    }
5145
5146    #[test]
5147    fn test_equivalent_base_type_spelling_is_not_a_diff() {
5148        // format_type() reports Postgres's own canonical alias spelling
5149        // (int8 -> bigint, timestamptz -> timestamp with time zone, ...) —
5150        // comparing against that verbatim would treat every existing
5151        // column as "changed" on every diff. Confirms the alias table
5152        // avoids that false positive.
5153        let mut td = simple_type("default", "Person", "Person");
5154        td.properties.push(prop("age", "int8", true));
5155        let schema = SchemaDescriptor {
5156            types: vec![td],
5157            scalars: vec![],
5158            enums: vec![],
5159            named_tuples: vec![],
5160            globals: vec![],
5161            functions: vec![],
5162            aliases: vec![],
5163            channels: vec![],
5164            ..Default::default()
5165        };
5166        let state = DbState {
5167            schemas: vec!["default".into()],
5168            tables: vec![DbTable {
5169                schema: "default".into(),
5170                name: "Person".into(),
5171                columns: vec![
5172                    DbColumn {
5173                        name: "id".into(),
5174                        pg_type: "uuid".into(),
5175                        nullable: false,
5176                        is_generated: false,
5177                        column_default: Some("uuidv7()".into()),
5178                    },
5179                    DbColumn {
5180                        name: "name".into(),
5181                        pg_type: "text".into(),
5182                        nullable: true,
5183                        is_generated: false,
5184                        column_default: None,
5185                    },
5186                    DbColumn {
5187                        name: "age".into(),
5188                        pg_type: "bigint".into(),
5189                        nullable: true,
5190                        is_generated: false,
5191                        column_default: None,
5192                    },
5193                ],
5194                foreign_keys: vec![],
5195                indexes: vec![],
5196                checks: vec![],
5197                triggers: vec!["pylon_cache_invalidate".into()],
5198            }],
5199            enums: vec![],
5200            domains: vec![],
5201            ..DbState::default()
5202        };
5203        let ops = diff_schema(&schema, &state).unwrap();
5204        assert!(
5205            ops.iter().all(|op| !op.contains("ALTER COLUMN")),
5206            "expected no ALTER COLUMN ops, got: {:?}",
5207            ops
5208        );
5209    }
5210
5211    #[test]
5212    fn test_check_the_schema_no_longer_declares_is_dropped() {
5213        // A check's name hashes its predicate, so editing one leaves the old
5214        // constraint in place beside the new — and both would have to hold.
5215        let mut person = simple_type("default", "Person", "Person");
5216        person.properties = vec![prop("id", "uuid", false)];
5217        let schema = SchemaDescriptor {
5218            types: vec![person],
5219            ..SchemaDescriptor::default()
5220        };
5221        let state = DbState {
5222            tables: vec![DbTable {
5223                schema: "default".into(),
5224                name: "Person".into(),
5225                columns: vec![DbColumn {
5226                    name: "id".into(),
5227                    pg_type: "uuid".into(),
5228                    nullable: false,
5229                    is_generated: false,
5230                    column_default: None,
5231                }],
5232                foreign_keys: vec![],
5233                indexes: vec![],
5234                checks: vec![
5235                    DbCheck {
5236                        constraint_name: "Person_name_deadbeef_check".into(),
5237                    },
5238                    DbCheck {
5239                        constraint_name: "a_hand_written_check".into(),
5240                    },
5241                ],
5242                triggers: vec![],
5243            }],
5244            ..DbState::default()
5245        };
5246        let ops = diff_schema_ops(&schema, &state).unwrap();
5247        let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5248        assert!(
5249            joined.contains("DROP CONSTRAINT IF EXISTS \"Person_name_deadbeef_check\""),
5250            "got:\n{joined}"
5251        );
5252        assert!(
5253            !joined.contains("a_hand_written_check"),
5254            "a constraint Pylon did not name is left alone:\n{joined}"
5255        );
5256    }
5257
5258    #[test]
5259    fn test_changed_scalar_domain_checks_are_reconciled() {
5260        use crate::schema::ScalarDescriptor;
5261        let schema = SchemaDescriptor {
5262            scalars: vec![ScalarDescriptor {
5263                name: "EmailStr".into(),
5264                module: "default".into(),
5265                base: "Str".into(),
5266                pg_type: "text".into(),
5267                check_constraints: vec!["char_length(VALUE) <= 320".into()],
5268                is_sequence: false,
5269            }],
5270            ..SchemaDescriptor::default()
5271        };
5272        let state = DbState {
5273            domains: vec![DbDomain {
5274                schema: "default".into(),
5275                name: "EmailStr".into(),
5276                checks: vec!["EmailStr_stale000_check".into()],
5277            }],
5278            ..DbState::default()
5279        };
5280        let ops = diff_schema_ops(&schema, &state).unwrap();
5281        let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5282        assert!(joined.contains("DROP CONSTRAINT"), "the old set is cleared:\n{joined}");
5283        assert!(
5284            joined.contains("ADD CONSTRAINT") && joined.contains("char_length(VALUE) <= 320"),
5285            "the new set is added:\n{joined}"
5286        );
5287    }
5288
5289    #[test]
5290    fn test_demoting_an_interface_to_a_mixin_drops_its_view() {
5291        // A mixin is flattened onto its implementors and has no relation of
5292        // its own, so the view the interface had must go with it.
5293        let mut individual = simple_type("default", "Individual", "Individual");
5294        individual.properties = vec![prop("id", "uuid", false)];
5295        let schema = SchemaDescriptor {
5296            types: vec![individual],
5297            ..SchemaDescriptor::default()
5298        };
5299        let state = DbState {
5300            tables: vec![],
5301            views: vec![DbView {
5302                schema: "public".into(),
5303                name: "Account".into(),
5304                body_hash: "whatever".into(),
5305            }],
5306            ..DbState::default()
5307        };
5308        let ops = diff_schema_ops(&schema, &state).unwrap();
5309        let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5310        assert!(
5311            joined.contains("DROP VIEW IF EXISTS \"public\".\"Account\""),
5312            "got:\n{joined}"
5313        );
5314    }
5315
5316    #[test]
5317    fn test_registered_scalar_domain_adoption_drops_and_recreates_dependent_interface_view() {
5318        // A property switching to a registered custom scalar's own DOMAIN
5319        // (PropertyDescriptor.column_type) on a table an interface view
5320        // selects from must drop that view before the ALTER (Postgres
5321        // refuses to retype a column a view depends on) and recreate it
5322        // afterward — not silently fail, and not leave the view missing.
5323        use crate::schema::ScalarDescriptor;
5324
5325        let mut account = simple_type("default", "Account", "Account");
5326        account.abstract_ = true;
5327        account.materialized = true;
5328        account.properties = vec![prop("id", "uuid", false), prop("email", "text", false)];
5329        account.properties[1].column_type = Some("\"public\".\"Email\"".into());
5330
5331        let mut individual = simple_type("default", "Individual", "Individual");
5332        individual.interfaces = vec!["default::Account".into()];
5333        individual.properties = vec![prop("id", "uuid", false), prop("email", "text", false)];
5334        individual.properties[1].column_type = Some("\"public\".\"Email\"".into());
5335
5336        let schema = SchemaDescriptor {
5337            types: vec![account, individual],
5338            scalars: vec![ScalarDescriptor {
5339                name: "Email".into(),
5340                module: "default".into(),
5341                base: "Str".into(),
5342                pg_type: "text".into(),
5343                check_constraints: vec!["value ~ '@'".into()],
5344                is_sequence: false,
5345            }],
5346            enums: vec![],
5347            named_tuples: vec![],
5348            globals: vec![],
5349            functions: vec![],
5350            aliases: vec![],
5351            channels: vec![],
5352            ..Default::default()
5353        };
5354
5355        // The view's own SELECT text never mentions column types, so its
5356        // hash is unaffected by this migration — matching that hash in
5357        // `current` confirms Phase 10 doesn't ALSO try to (redundantly,
5358        // and invalidly, since it'd already exist) recreate it.
5359        let view_ddl = crate::export::interface_view_ddl_with_names(&schema)
5360            .into_iter()
5361            .find(|(_, n, _)| n == "Account")
5362            .unwrap()
5363            .2;
5364
5365        let state = DbState {
5366            schemas: vec!["default".into()],
5367            tables: vec![DbTable {
5368                schema: "default".into(),
5369                name: "Individual".into(),
5370                columns: vec![
5371                    DbColumn {
5372                        name: "id".into(),
5373                        pg_type: "uuid".into(),
5374                        nullable: false,
5375                        is_generated: false,
5376                        column_default: Some("uuidv7()".into()),
5377                    },
5378                    DbColumn {
5379                        name: "email".into(),
5380                        pg_type: "text".into(),
5381                        nullable: false,
5382                        is_generated: false,
5383                        column_default: None,
5384                    },
5385                ],
5386                foreign_keys: vec![],
5387                indexes: vec![],
5388                checks: vec![],
5389                triggers: vec!["pylon_cache_invalidate".into()],
5390            }],
5391            views: vec![DbView {
5392                schema: "default".into(),
5393                name: "Account".into(),
5394                body_hash: ddl_hash(&view_ddl),
5395            }],
5396            enums: vec![],
5397            domains: vec![],
5398            ..DbState::default()
5399        };
5400
5401        // Migration mode (`diff_schema_ops`, what `pylon migration create`
5402        // uses) — unlike watch mode, this respects the interface view's
5403        // unchanged DDL hash and doesn't redundantly re-emit it.
5404        let ops = diff_schema_ops(&schema, &state).unwrap();
5405        let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5406        let drop_pos = joined
5407            .find("DROP VIEW IF EXISTS \"public\".\"Account\"")
5408            .unwrap_or_else(|| panic!("missing DROP VIEW; got:\n{joined}"));
5409        let alter_pos = joined
5410            .find("ALTER TABLE \"public\".\"Individual\" ALTER COLUMN \"email\" TYPE \"public\".\"Email\"")
5411            .unwrap_or_else(|| panic!("missing ALTER COLUMN TYPE; got:\n{joined}"));
5412        let create_pos = joined
5413            .rfind("CREATE VIEW \"public\".\"Account\"")
5414            .unwrap_or_else(|| panic!("missing CREATE VIEW; got:\n{joined}"));
5415        assert!(drop_pos < alter_pos, "DROP VIEW must precede the ALTER; got:\n{joined}");
5416        assert!(
5417            alter_pos < create_pos,
5418            "CREATE VIEW must follow the ALTER; got:\n{joined}"
5419        );
5420        assert_eq!(
5421            joined.matches("CREATE VIEW \"public\".\"Account\"").count(),
5422            1,
5423            "view must be recreated exactly once, not duplicated by Phase 10; got:\n{joined}"
5424        );
5425    }
5426
5427    fn exclusive_email_account_schema(implementor_names: &[&str]) -> SchemaDescriptor {
5428        let mut account = simple_type("default", "Account", "Account");
5429        account.abstract_ = true;
5430        account.materialized = true;
5431        account.properties = vec![prop("id", "uuid", false), prop("email", "text", false)];
5432        account.properties[1].is_exclusive = true;
5433
5434        let mut types = vec![account];
5435        for name in implementor_names {
5436            let mut t = simple_type("default", name, name);
5437            t.interfaces = vec!["default::Account".into()];
5438            t.properties = vec![prop("id", "uuid", false), prop("email", "text", false)];
5439            t.properties[1].is_exclusive = true;
5440            types.push(t);
5441        }
5442        SchemaDescriptor {
5443            types,
5444            scalars: vec![],
5445            enums: vec![],
5446            named_tuples: vec![],
5447            globals: vec![],
5448            functions: vec![],
5449            aliases: vec![],
5450            channels: vec![],
5451            ..Default::default()
5452        }
5453    }
5454
5455    #[test]
5456    fn test_new_implementor_added_to_existing_interface_gets_exclusive_triggers_retroactively() {
5457        // `Individual` already exists (with its own triggers already
5458        // applied by a prior migration); `Organization` is a brand new
5459        // implementor of the same interface — it must get the cross-table
5460        // exclusive trigger from the moment its table is created, and
5461        // `Individual`'s already-present triggers must not be re-emitted.
5462        let schema = exclusive_email_account_schema(&["Individual", "Organization"]);
5463        let state = DbState {
5464            schemas: vec!["default".into()],
5465            tables: vec![DbTable {
5466                schema: "default".into(),
5467                name: "Individual".into(),
5468                columns: vec![
5469                    DbColumn {
5470                        name: "id".into(),
5471                        pg_type: "uuid".into(),
5472                        nullable: false,
5473                        is_generated: false,
5474                        column_default: Some("uuidv7()".into()),
5475                    },
5476                    DbColumn {
5477                        name: "email".into(),
5478                        pg_type: "text".into(),
5479                        nullable: false,
5480                        is_generated: false,
5481                        column_default: None,
5482                    },
5483                ],
5484                foreign_keys: vec![],
5485                indexes: vec![],
5486                checks: vec![],
5487                triggers: vec![
5488                    "pylon_cache_invalidate".into(),
5489                    "_excl_Account_email_ins".into(),
5490                    "_excl_Account_email_upd".into(),
5491                ],
5492            }],
5493            enums: vec![],
5494            domains: vec![],
5495            ..DbState::default()
5496        };
5497
5498        let ops = diff_schema_ops(&schema, &state).unwrap();
5499        let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5500        assert!(
5501            joined.contains(
5502                "CREATE CONSTRAINT TRIGGER \"_excl_Account_email_ins\"\nAFTER INSERT ON \"public\".\"Organization\""
5503            ),
5504            "the new implementor must get the exclusive trigger; got:\n{joined}"
5505        );
5506        assert!(
5507            !joined.contains("ON \"public\".\"Individual\""),
5508            "the already-migrated implementor's existing triggers must not be re-emitted; got:\n{joined}"
5509        );
5510    }
5511
5512    #[test]
5513    fn test_the_shared_exclusive_guard_function_precedes_every_implementors_trigger() {
5514        // One guard function serves every implementor, so it is emitted with
5515        // a single one of them -- and that has to be whichever implementor's
5516        // step renders first. `Individual` is declared first but links to
5517        // `Organization`, so topological order creates `Organization`'s table
5518        // (and its trigger) ahead of it.
5519        let mut schema = exclusive_email_account_schema(&["Individual", "Organization"]);
5520        let individual = schema
5521            .types
5522            .iter_mut()
5523            .find(|t| t.name == "Individual")
5524            .expect("Individual is in the schema");
5525        individual.links.push(LinkDescriptor {
5526            name: "employer".into(),
5527            target: "default::Organization".into(),
5528            nullable: false,
5529            through: None,
5530            description: None,
5531            default_pyql: None,
5532            is_exclusive: false,
5533            is_readonly: false,
5534            rewrites: vec![],
5535            on_delete: vec![],
5536        });
5537
5538        let ops = diff_schema_ops(&schema, &DbState::default()).unwrap();
5539        let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5540        let function_pos = joined
5541            .find("CREATE OR REPLACE FUNCTION \"public\".\"_excl_Account_email\"")
5542            .unwrap_or_else(|| panic!("missing the guard function; got:\n{joined}"));
5543        let first_trigger_pos = joined
5544            .find("CREATE CONSTRAINT TRIGGER \"_excl_Account_email_ins\"")
5545            .unwrap_or_else(|| panic!("missing the exclusive triggers; got:\n{joined}"));
5546        assert!(
5547            function_pos < first_trigger_pos,
5548            "the guard function must be defined before the first trigger naming it; got:\n{joined}"
5549        );
5550        assert_eq!(
5551            joined
5552                .matches("CREATE OR REPLACE FUNCTION \"public\".\"_excl_Account_email\"")
5553                .count(),
5554            1,
5555            "the guard function must be emitted exactly once; got:\n{joined}"
5556        );
5557    }
5558
5559    #[test]
5560    fn test_removing_exclusivity_drops_the_cross_table_triggers() {
5561        // The target schema no longer marks `email` exclusive at all (the
5562        // user removed `Exclusive` from the interface) — both the INSERT
5563        // and UPDATE constraint triggers on every implementor must be
5564        // dropped, matching any other trigger's removal.
5565        let mut schema = exclusive_email_account_schema(&["Individual"]);
5566        for t in &mut schema.types {
5567            for p in &mut t.properties {
5568                if p.name == "email" {
5569                    p.is_exclusive = false;
5570                }
5571            }
5572        }
5573        let state = DbState {
5574            schemas: vec!["default".into()],
5575            tables: vec![DbTable {
5576                schema: "default".into(),
5577                name: "Individual".into(),
5578                columns: vec![
5579                    DbColumn {
5580                        name: "id".into(),
5581                        pg_type: "uuid".into(),
5582                        nullable: false,
5583                        is_generated: false,
5584                        column_default: Some("uuidv7()".into()),
5585                    },
5586                    DbColumn {
5587                        name: "email".into(),
5588                        pg_type: "text".into(),
5589                        nullable: false,
5590                        is_generated: false,
5591                        column_default: None,
5592                    },
5593                ],
5594                foreign_keys: vec![],
5595                indexes: vec![],
5596                checks: vec![],
5597                triggers: vec![
5598                    "pylon_cache_invalidate".into(),
5599                    "_excl_Account_email_ins".into(),
5600                    "_excl_Account_email_upd".into(),
5601                ],
5602            }],
5603            enums: vec![],
5604            domains: vec![],
5605            ..DbState::default()
5606        };
5607
5608        let ops = diff_schema_ops(&schema, &state).unwrap();
5609        let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5610        assert!(
5611            joined.contains("DROP TRIGGER IF EXISTS \"_excl_Account_email_ins\" ON \"public\".\"Individual\""),
5612            "got:\n{joined}"
5613        );
5614        assert!(
5615            joined.contains("DROP TRIGGER IF EXISTS \"_excl_Account_email_upd\" ON \"public\".\"Individual\""),
5616            "got:\n{joined}"
5617        );
5618        assert!(
5619            !joined.contains("DROP TRIGGER IF EXISTS \"pylon_cache_invalidate\""),
5620            "unrelated triggers must not be touched; got:\n{joined}"
5621        );
5622    }
5623
5624    #[test]
5625    fn test_new_enum() {
5626        let schema = SchemaDescriptor {
5627            types: vec![],
5628            scalars: vec![],
5629            enums: vec![EnumDescriptor {
5630                name: "Status".into(),
5631                module: "default".into(),
5632                members: vec!["Active".into(), "Inactive".into()],
5633            }],
5634            named_tuples: vec![],
5635            globals: vec![],
5636            functions: vec![],
5637            aliases: vec![],
5638            channels: vec![],
5639            ..Default::default()
5640        };
5641        let ops = diff_schema(&schema, &empty_state()).unwrap();
5642        let joined = ops.join("\n");
5643        assert!(
5644            joined.contains("CREATE TYPE \"public\".\"Status\" AS ENUM"),
5645            "got:\n{joined}"
5646        );
5647    }
5648
5649    #[test]
5650    fn test_drop_table() {
5651        let schema = SchemaDescriptor {
5652            types: vec![],
5653            scalars: vec![],
5654            enums: vec![],
5655            named_tuples: vec![],
5656            globals: vec![],
5657            functions: vec![],
5658            aliases: vec![],
5659            channels: vec![],
5660            ..Default::default()
5661        };
5662        let state = DbState {
5663            schemas: vec!["default".into()],
5664            tables: vec![DbTable {
5665                schema: "default".into(),
5666                name: "OldType".into(),
5667                columns: vec![],
5668                foreign_keys: vec![],
5669                indexes: vec![],
5670                checks: vec![],
5671                triggers: vec![],
5672            }],
5673            enums: vec![],
5674            domains: vec![],
5675            ..DbState::default()
5676        };
5677        let ops = diff_schema(&schema, &state).unwrap();
5678        let joined = ops.join("\n");
5679        assert!(
5680            joined.contains("DROP TABLE IF EXISTS \"public\".\"OldType\" CASCADE"),
5681            "got:\n{joined}"
5682        );
5683    }
5684
5685    #[test]
5686    fn test_index_on_existing_table_is_concurrently() {
5687        use crate::schema::VectorIndexDescriptor;
5688        let mut td = simple_type("default", "Post", "Post");
5689        td.vector_indexes.push(VectorIndexDescriptor {
5690            index_name: None,
5691            pointers: vec!["name".into()],
5692            model: "test".into(),
5693            metric: "cosine".into(),
5694            dimensions: 1536,
5695        });
5696        let schema = SchemaDescriptor {
5697            types: vec![td],
5698            scalars: vec![],
5699            enums: vec![],
5700            named_tuples: vec![],
5701            globals: vec![],
5702            functions: vec![],
5703            aliases: vec![],
5704            channels: vec![],
5705            ..Default::default()
5706        };
5707        // The table already exists in the DB (pre-existing).
5708        let state = DbState {
5709            schemas: vec!["default".into()],
5710            tables: vec![DbTable {
5711                schema: "default".into(),
5712                name: "Post".into(),
5713                columns: vec![
5714                    DbColumn {
5715                        name: "id".into(),
5716                        pg_type: "uuid".into(),
5717                        nullable: false,
5718                        is_generated: false,
5719                        column_default: Some("uuidv7()".into()),
5720                    },
5721                    DbColumn {
5722                        name: "name".into(),
5723                        pg_type: "text".into(),
5724                        nullable: true,
5725                        is_generated: false,
5726                        column_default: None,
5727                    },
5728                ],
5729                foreign_keys: vec![],
5730                indexes: vec![],
5731                checks: vec![],
5732                triggers: vec![],
5733            }],
5734            enums: vec![],
5735            domains: vec![],
5736            ..DbState::default()
5737        };
5738        let ops = diff_schema_ops(&schema, &state).unwrap();
5739        let idx_op = ops.iter().find(|op| op.sql.contains("hnsw")).unwrap();
5740        assert!(
5741            idx_op.non_transactional,
5742            "index on pre-existing table should be non-transactional"
5743        );
5744        assert!(
5745            idx_op.sql.contains("CONCURRENTLY"),
5746            "should use CONCURRENTLY: {}",
5747            idx_op.sql
5748        );
5749    }
5750
5751    #[test]
5752    fn test_required_extensions_empty_without_vector_indexes() {
5753        let schema = SchemaDescriptor {
5754            types: vec![simple_type("default", "Post", "Post")],
5755            scalars: vec![],
5756            enums: vec![],
5757            named_tuples: vec![],
5758            globals: vec![],
5759            functions: vec![],
5760            aliases: vec![],
5761            channels: vec![],
5762            ..Default::default()
5763        };
5764        assert!(required_extensions(&schema).is_empty());
5765    }
5766
5767    #[test]
5768    fn test_missing_extension_ddl_when_vector_index_present_and_not_yet_installed() {
5769        use crate::schema::VectorIndexDescriptor;
5770        let mut td = simple_type("default", "Post", "Post");
5771        td.vector_indexes.push(VectorIndexDescriptor {
5772            index_name: None,
5773            pointers: vec!["name".into()],
5774            model: "test".into(),
5775            metric: "cosine".into(),
5776            dimensions: 1536,
5777        });
5778        let schema = SchemaDescriptor {
5779            types: vec![td],
5780            scalars: vec![],
5781            enums: vec![],
5782            named_tuples: vec![],
5783            globals: vec![],
5784            functions: vec![],
5785            aliases: vec![],
5786            channels: vec![],
5787            ..Default::default()
5788        };
5789        assert_eq!(required_extensions(&schema), vec!["vector"]);
5790
5791        let ddl = missing_extension_ddl(&schema, &DbState::default());
5792        assert_eq!(ddl, vec!["CREATE EXTENSION IF NOT EXISTS \"vector\";".to_string()]);
5793
5794        let already_installed = DbState {
5795            extensions: vec!["vector".into()],
5796            ..DbState::default()
5797        };
5798        assert!(missing_extension_ddl(&schema, &already_installed).is_empty());
5799    }
5800
5801    #[test]
5802    fn test_index_on_new_table_is_transactional() {
5803        use crate::schema::VectorIndexDescriptor;
5804        let mut td = simple_type("default", "Post", "Post");
5805        td.vector_indexes.push(VectorIndexDescriptor {
5806            index_name: None,
5807            pointers: vec!["name".into()],
5808            model: "test".into(),
5809            metric: "cosine".into(),
5810            dimensions: 1536,
5811        });
5812        let schema = SchemaDescriptor {
5813            types: vec![td],
5814            scalars: vec![],
5815            enums: vec![],
5816            named_tuples: vec![],
5817            globals: vec![],
5818            functions: vec![],
5819            aliases: vec![],
5820            channels: vec![],
5821            ..Default::default()
5822        };
5823        // Table does NOT exist in the DB → it's new.
5824        let ops = diff_schema_ops(&schema, &empty_state()).unwrap();
5825        let idx_op = ops.iter().find(|op| op.sql.contains("hnsw")).unwrap();
5826        assert!(!idx_op.non_transactional, "index on new table should be transactional");
5827        assert!(
5828            !idx_op.sql.contains("CONCURRENTLY"),
5829            "should NOT use CONCURRENTLY: {}",
5830            idx_op.sql
5831        );
5832    }
5833
5834    fn sequence_scalar(module: &str, name: &str) -> crate::schema::ScalarDescriptor {
5835        crate::schema::ScalarDescriptor {
5836            name: name.into(),
5837            module: module.into(),
5838            base: "Sequence".into(),
5839            pg_type: "int8".into(),
5840            check_constraints: vec![],
5841            is_sequence: true,
5842        }
5843    }
5844
5845    #[test]
5846    fn test_new_sequence_creates_sequence_and_domain() {
5847        let schema = SchemaDescriptor {
5848            types: vec![],
5849            enums: vec![],
5850            named_tuples: vec![],
5851            globals: vec![],
5852            functions: vec![],
5853            aliases: vec![],
5854            channels: vec![],
5855            scalars: vec![sequence_scalar("default", "OrderNumber")],
5856            ..Default::default()
5857        };
5858        let ops = diff_schema(&schema, &empty_state()).unwrap();
5859        let joined = ops.join("\n");
5860        assert!(
5861            joined.contains("CREATE SEQUENCE IF NOT EXISTS \"public\".\"OrderNumber_seq\""),
5862            "got:\n{joined}"
5863        );
5864        assert!(
5865            joined.contains("CREATE DOMAIN \"public\".\"OrderNumber\" AS int8"),
5866            "got:\n{joined}"
5867        );
5868        // Sequence must precede domain
5869        let seq_pos = joined.find("CREATE SEQUENCE").unwrap();
5870        let dom_pos = joined.find("CREATE DOMAIN").unwrap();
5871        assert!(seq_pos < dom_pos, "sequence must be created before domain");
5872    }
5873
5874    #[test]
5875    fn test_no_ops_sequence_already_exists() {
5876        let schema = SchemaDescriptor {
5877            types: vec![],
5878            enums: vec![],
5879            named_tuples: vec![],
5880            globals: vec![],
5881            functions: vec![],
5882            aliases: vec![],
5883            channels: vec![],
5884            scalars: vec![sequence_scalar("default", "OrderNumber")],
5885            ..Default::default()
5886        };
5887        let state = DbState {
5888            schemas: vec!["default".into()],
5889            domains: vec![DbDomain {
5890                schema: "default".into(),
5891                name: "OrderNumber".into(),
5892                checks: vec![],
5893            }],
5894            sequences: vec![DbSequence {
5895                schema: "default".into(),
5896                name: "OrderNumber_seq".into(),
5897            }],
5898            ..DbState::default()
5899        };
5900        let ops = diff_schema(&schema, &state).unwrap();
5901        assert!(
5902            ops.is_empty(),
5903            "expected no ops when sequence and domain exist, got: {:?}",
5904            ops
5905        );
5906    }
5907
5908    #[test]
5909    fn test_drop_removed_sequence() {
5910        let schema = SchemaDescriptor {
5911            types: vec![],
5912            scalars: vec![],
5913            enums: vec![],
5914            named_tuples: vec![],
5915            globals: vec![],
5916            functions: vec![],
5917            aliases: vec![],
5918            channels: vec![],
5919            ..Default::default()
5920        };
5921        let state = DbState {
5922            schemas: vec!["default".into()],
5923            domains: vec![DbDomain {
5924                schema: "default".into(),
5925                name: "OrderNumber".into(),
5926                checks: vec![],
5927            }],
5928            sequences: vec![DbSequence {
5929                schema: "default".into(),
5930                name: "OrderNumber_seq".into(),
5931            }],
5932            ..DbState::default()
5933        };
5934        let ops = diff_schema(&schema, &state).unwrap();
5935        let joined = ops.join("\n");
5936        assert!(
5937            joined.contains("DROP DOMAIN IF EXISTS \"public\".\"OrderNumber\""),
5938            "got:\n{joined}"
5939        );
5940        assert!(
5941            joined.contains("DROP SEQUENCE IF EXISTS \"public\".\"OrderNumber_seq\""),
5942            "got:\n{joined}"
5943        );
5944    }
5945
5946    // ── MigrationStep grouping ───────────────────────────────────────────────
5947
5948    #[test]
5949    fn test_diff_schema_steps_groups_multiple_column_changes_into_one_alter_step() {
5950        let mut person = simple_type("default", "Person", "Person");
5951        person.properties.push(prop("nickname", "text", true));
5952        person.properties.push(prop("age", "int8", true));
5953        let schema = SchemaDescriptor {
5954            types: vec![person],
5955            scalars: vec![],
5956            enums: vec![],
5957            named_tuples: vec![],
5958            globals: vec![],
5959            functions: vec![],
5960            aliases: vec![],
5961            channels: vec![],
5962            ..Default::default()
5963        };
5964        let state = DbState {
5965            schemas: vec!["default".into()],
5966            tables: vec![DbTable {
5967                schema: "default".into(),
5968                name: "Person".into(),
5969                columns: vec![
5970                    DbColumn {
5971                        name: "id".into(),
5972                        pg_type: "uuid".into(),
5973                        nullable: false,
5974                        is_generated: false,
5975                        column_default: Some("uuidv7()".into()),
5976                    },
5977                    DbColumn {
5978                        name: "name".into(),
5979                        pg_type: "text".into(),
5980                        nullable: true,
5981                        is_generated: false,
5982                        column_default: None,
5983                    },
5984                ],
5985                foreign_keys: vec![],
5986                indexes: vec![],
5987                checks: vec![],
5988                triggers: vec!["pylon_cache_invalidate".into()],
5989            }],
5990            enums: vec![],
5991            domains: vec![],
5992            ..DbState::default()
5993        };
5994
5995        let steps = diff_schema_steps(&schema, &state, &HashMap::new()).unwrap();
5996        let table_steps: Vec<&MigrationStep> = steps
5997            .iter()
5998            .filter(|s| matches!(&s.op_key, OpKey::Table(m, t) if m == "default" && t == "Person"))
5999            .collect();
6000        assert_eq!(
6001            table_steps.len(),
6002            1,
6003            "two new columns on the same table must produce one step, got: {:?}",
6004            steps.iter().map(|s| &s.prompt).collect::<Vec<_>>()
6005        );
6006        assert_eq!(table_steps[0].verb, Verb::Alter);
6007        assert_eq!(table_steps[0].prompt, "did you alter object type 'default::Person'?");
6008        assert_eq!(
6009            table_steps[0].ddl.len(),
6010            2,
6011            "expected one ADD COLUMN per new property, got: {:?}",
6012            table_steps[0].ddl.iter().map(|d| &d.sql).collect::<Vec<_>>()
6013        );
6014    }
6015
6016    #[test]
6017    fn test_diff_schema_steps_new_table_is_one_create_step_including_its_trigger() {
6018        let schema = SchemaDescriptor {
6019            types: vec![simple_type("catalog", "Product", "Product")],
6020            scalars: vec![],
6021            enums: vec![],
6022            named_tuples: vec![],
6023            globals: vec![],
6024            functions: vec![],
6025            aliases: vec![],
6026            channels: vec![],
6027            ..Default::default()
6028        };
6029        let steps = diff_schema_steps(&schema, &empty_state(), &HashMap::new()).unwrap();
6030        let table_steps: Vec<&MigrationStep> = steps
6031            .iter()
6032            .filter(|s| matches!(&s.op_key, OpKey::Table(m, t) if m == "catalog" && t == "Product"))
6033            .collect();
6034        assert_eq!(
6035            table_steps.len(),
6036            1,
6037            "got steps: {:?}",
6038            steps.iter().map(|s| &s.prompt).collect::<Vec<_>>()
6039        );
6040        assert_eq!(table_steps[0].verb, Verb::Create);
6041        assert_eq!(table_steps[0].prompt, "did you create object type 'catalog::Product'?");
6042
6043        // The cache-invalidation trigger for a brand new table must fold
6044        // into this same create step, not a separate one (Phase 11.5).
6045        let joined: String = table_steps[0]
6046            .ddl
6047            .iter()
6048            .map(|d| d.sql.as_str())
6049            .collect::<Vec<_>>()
6050            .join("\n");
6051        assert!(
6052            joined.contains("CREATE TABLE IF NOT EXISTS \"catalog\".\"Product\""),
6053            "got:\n{joined}"
6054        );
6055        assert!(joined.contains("pylon_cache_invalidate"), "got:\n{joined}");
6056    }
6057
6058    #[test]
6059    fn test_guidance_bans_a_rejected_type_rename_candidate() {
6060        let schema = SchemaDescriptor {
6061            types: vec![simple_type("default", "Customer", "Customer")],
6062            scalars: vec![],
6063            enums: vec![],
6064            named_tuples: vec![],
6065            globals: vec![],
6066            functions: vec![],
6067            aliases: vec![],
6068            channels: vec![],
6069            ..Default::default()
6070        };
6071        let state = DbState {
6072            schemas: vec!["default".into()],
6073            tables: vec![DbTable {
6074                schema: "default".into(),
6075                name: "Person".into(),
6076                columns: vec![
6077                    DbColumn {
6078                        name: "id".into(),
6079                        pg_type: "uuid".into(),
6080                        nullable: false,
6081                        is_generated: false,
6082                        column_default: Some("uuidv7()".into()),
6083                    },
6084                    DbColumn {
6085                        name: "name".into(),
6086                        pg_type: "text".into(),
6087                        nullable: true,
6088                        is_generated: false,
6089                        column_default: None,
6090                    },
6091                ],
6092                foreign_keys: vec![],
6093                indexes: vec![],
6094                checks: vec![],
6095                triggers: vec![],
6096            }],
6097            enums: vec![],
6098            domains: vec![],
6099            ..DbState::default()
6100        };
6101
6102        let candidates = detect_type_renames(&schema, &state, &Guidance::default());
6103        assert_eq!(
6104            candidates.len(),
6105            1,
6106            "expected Person -> Customer to be proposed as a rename"
6107        );
6108
6109        let mut guidance = Guidance::default();
6110        guidance.banned_type_renames.insert((
6111            "default".to_string(),
6112            "Person".to_string(),
6113            "default".to_string(),
6114            "Customer".to_string(),
6115        ));
6116        let candidates = detect_type_renames(&schema, &state, &guidance);
6117        assert!(
6118            candidates.is_empty(),
6119            "a banned rename candidate must not be re-proposed"
6120        );
6121    }
6122}