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