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