Skip to main content

umbral_core/
migrate.rs

1//! The migration engine — the north star.
2//!
3//! Implements the **declare → migrate → change → migrate** cycle from
4//! `arch.md §0`. Users declare or change a model, run `migrate`, and the
5//! framework either generates the missing migration file (via `make`)
6//! or applies pending migration files (via `run`).
7//!
8//! At M5 (this milestone) the surface ships:
9//!
10//! - A process-wide [`ModelRegistry`] populated by
11//!   `App::builder().model::<T>()`.
12//! - A [`Snapshot`] of every registered model's metadata, JSON-
13//!   serialisable so it can live inside a migration file's
14//!   `snapshot_after`.
15//! - An [`Operation`] enum with the two minimum-viable ops:
16//!   [`Operation::CreateTable`] and [`Operation::DropTable`]. Column-
17//!   level ops (`AddColumn`, `DropColumn`, `AlterColumn`) land at M8
18//!   alongside rename detection (per `arch.md §7` and
19//!   `docs/specs/06-migration-engine.md`). The "M5.1" label in the
20//!   `UnsupportedChange` error message is shorthand for the same slot.
21//! - A [`MigrationFile`] format (one JSON file per migration) carrying
22//!   `id`, `operations`, and `snapshot_after`.
23//! - The `umbral_migrations` tracking table (one row per applied
24//!   migration, keyed by `(plugin, name)`).
25//! - High-level entry points: [`make`], [`run`], [`show`].
26//!
27//! Reserved for M5.1+:
28//!
29//! - Column-level ops (`AddColumn`, `DropColumn`, `AlterColumn`).
30//! - Rename-detection vs drop+add disambiguation (spec 06 §M8).
31//! - `RunSql` / `RunCode` data-migration ops.
32//! - Squashing, `--fake`, `--fake-initial` (PRD F-MIG-6 P2).
33//! - Cross-plugin migration dependencies (needs M7 Plugin contract).
34//!
35//! See `docs/specs/06-migration-engine.md` for the full target shape.
36
37use std::path::{Path, PathBuf};
38use std::sync::OnceLock;
39
40use serde::{Deserialize, Serialize};
41
42use crate::backend::DatabaseBackend;
43use crate::orm::{FieldSpec, Model, SqlType};
44
45/// Per-process model registry. Published by `AppBuilder::build()`
46/// after `.model::<T>()` calls and `.plugin(...)` registrations
47/// collected metadata into the builder.
48///
49/// Stored as a flat vector of `(plugin_name, model)` pairs so M5's
50/// existing `registered_models()` keeps working (drop the plugin
51/// names) and the M7 plugin-aware walks (`registered_plugins`,
52/// `models_for_plugin`) can read the same source of truth without a
53/// second registry. The plugin name `"app"` covers models registered
54/// via `.model::<T>()`; every other name is a real Plugin's.
55static REGISTRY: OnceLock<Vec<(String, ModelMeta)>> = OnceLock::new();
56
57/// Initialize the registry with one entry per plugin.
58///
59/// `App::build()` calls this after collecting `.model::<T>()` into the
60/// implicit `"app"` plugin and walking every registered plugin's
61/// `Plugin::models()`. Plugins missing from the map contribute zero
62/// models (default-noop `models()` returns an empty vec; the entry
63/// can be omitted).
64pub(crate) fn init_plugins(per_plugin: std::collections::HashMap<String, Vec<ModelMeta>>) {
65    let mut flat: Vec<(String, ModelMeta)> = Vec::new();
66    let mut plugin_names: Vec<String> = per_plugin.keys().cloned().collect();
67    plugin_names.sort();
68    for plugin in plugin_names {
69        for m in per_plugin.get(&plugin).cloned().unwrap_or_default() {
70            flat.push((plugin.clone(), m));
71        }
72    }
73    REGISTRY
74        .set(flat)
75        .expect("umbral::migrate::init_plugins called more than once");
76}
77
78/// Return every registered model, flat. Drops the per-plugin grouping;
79/// useful when the caller only needs the model set (e.g. M5's `make`
80/// when the codebase only had a single `"app"` plugin).
81///
82/// # Panics
83///
84/// Panics if `App::build()` hasn't run.
85pub fn registered_models() -> Vec<ModelMeta> {
86    REGISTRY
87        .get()
88        .expect("umbral: model registry not initialised — did you call App::build()?")
89        .iter()
90        .map(|(_, m)| m.clone())
91        .collect()
92}
93
94/// Whether the model registry has been initialised. False before
95/// `App::build()` has run; true after the phase-3 `init_plugins`
96/// call publishes the per-plugin map. Used by system checks that
97/// walk the registry — they return an empty result when the
98/// registry isn't ready rather than panicking (so low-level tests
99/// that drive `check::run_all` without booting an App keep working).
100pub fn is_initialised() -> bool {
101    REGISTRY.get().is_some()
102}
103
104/// PK lift Pass E — cached `(pk_column_name, pk_sql_type)` lookup
105/// keyed by table name. Used by the FK decode path
106/// (`fk_target_pk_sql_type` in `orm/dynamic.rs`) and the
107/// select_related hydrators, both of which previously cloned the
108/// full `Vec<ModelMeta>` per call and linear-scanned for the
109/// target's PK column.
110///
111/// REGISTRY is a `OnceLock` set once during `App::build`; this cache
112/// reads from it the first time anyone asks for a PK lookup AFTER
113/// initialisation, then serves from a `HashMap` for every
114/// subsequent call. Eliminates the per-row `registered_models()`
115/// clone in hot decode loops.
116///
117/// Returns `None` when the registry isn't initialised (the cache
118/// stays uninstantiated so a follow-up call after `App::build`
119/// gets the real table set), OR when the named table isn't in the
120/// registry (orphan / system / typo).
121pub fn pk_meta_for_table(table: &str) -> Option<(String, crate::orm::SqlType)> {
122    if !is_initialised() {
123        // Defer cache init until App::build has populated REGISTRY.
124        // The cache MUST NOT memoize an empty map; otherwise
125        // post-init callers would see no PK metadata forever.
126        return None;
127    }
128    static CACHE: std::sync::OnceLock<
129        std::collections::HashMap<String, (String, crate::orm::SqlType)>,
130    > = std::sync::OnceLock::new();
131    let map = CACHE.get_or_init(|| {
132        let mut out = std::collections::HashMap::new();
133        for m in registered_models() {
134            if let Some(pk) = m.pk_column() {
135                out.insert(m.table.clone(), (pk.name.clone(), pk.ty));
136            }
137        }
138        out
139    });
140    map.get(table).cloned()
141}
142
143/// Cached model lookup by SQL table name.
144///
145/// Unlike [`registered_models`], this does not deep-clone the full
146/// registry on every call. It clones only the matched [`ModelMeta`],
147/// which keeps row-by-row dynamic serializers from paying
148/// O(registry-size) per row.
149pub fn model_meta_for_table(table: &str) -> Option<ModelMeta> {
150    if !is_initialised() {
151        return None;
152    }
153    static CACHE: std::sync::OnceLock<std::collections::HashMap<String, ModelMeta>> =
154        std::sync::OnceLock::new();
155    let map = CACHE.get_or_init(|| {
156        registered_models()
157            .into_iter()
158            .map(|m| (m.table.clone(), m))
159            .collect()
160    });
161    map.get(table).cloned()
162}
163
164/// The SQL type a column's value actually binds / decodes as (PK lift).
165/// Equals `col.ty` for everything except a `ForeignKey`, where it resolves
166/// to the referenced model's PK type via [`pk_meta_for_table`] — so an FK
167/// pointing at a `String`-slug- or `Uuid`-PK target is handled as text /
168/// uuid instead of being forced through i64. Falls back to `BigInt` (the
169/// historical default) when the target can't be resolved (registry not yet
170/// initialised, or an unregistered target table).
171///
172/// The single source of truth for "what shape is this FK really?", used by
173/// `backup` (dump/load) and the dynamic filter helpers.
174pub fn fk_effective_type(col: &Column) -> crate::orm::SqlType {
175    if matches!(col.ty, crate::orm::SqlType::ForeignKey) {
176        col.fk_target
177            .as_deref()
178            .and_then(pk_meta_for_table)
179            .map(|(_, ty)| ty)
180            .unwrap_or(crate::orm::SqlType::BigInt)
181    } else {
182        col.ty
183    }
184}
185
186/// Return the registered plugin names that contributed at least one
187/// model. Sorted deterministically. Used as a fallback when no
188/// topological order is published; the M7 walk used this directly,
189/// and M8 prefers [`plugin_order`] when it's been set.
190pub fn registered_plugins() -> Vec<String> {
191    let mut names: Vec<String> = REGISTRY
192        .get()
193        .expect("umbral: model registry not initialised — did you call App::build()?")
194        .iter()
195        .map(|(p, _)| p.clone())
196        .collect();
197    names.sort();
198    names.dedup();
199    names
200}
201
202/// The topological plugin order published by `App::build()` after its
203/// phase 1.5 sort. `None` until that runs; the CLI subcommands
204/// (`makemigrations`, `migrate`, `showmigrations`) call `App::build()`
205/// via `boot_for_management` before reaching the migration engine.
206static PLUGIN_ORDER: OnceLock<Vec<String>> = OnceLock::new();
207
208/// Per-model database alias (`Model::NAME -> alias`) published by
209/// `App::build()` after walking each registered plugin's
210/// `Plugin::database()`. Models whose plugin returned `None` are
211/// absent from the map; QuerySet's `resolve_pool` falls back to the
212/// `"default"` alias for those. Lookup is `O(1)` on a `HashMap`.
213static MODEL_ALIASES: OnceLock<std::collections::HashMap<String, String>> = OnceLock::new();
214
215/// Publish the topological plugin order. Called by `App::build()` once
216/// the phase 1.5 sort has produced the order. Must include the
217/// implicit `"app"` plugin even when no real plugins are registered.
218pub(crate) fn init_plugin_order(order: Vec<String>) {
219    PLUGIN_ORDER
220        .set(order)
221        .expect("umbral::migrate::init_plugin_order called more than once");
222}
223
224/// Return the topological plugin order if `App::build()` published
225/// one; otherwise fall back to [`registered_plugins`] (sorted by
226/// name). The fallback keeps existing M5 / M6 tests working without
227/// requiring them to wire a full plugin sort.
228pub fn plugin_order() -> Vec<String> {
229    PLUGIN_ORDER
230        .get()
231        .cloned()
232        .unwrap_or_else(registered_plugins)
233}
234
235/// The client-facing API endpoints every registered plugin advertised
236/// via `Plugin::api_endpoints()`, collected by `App::build()`. `None`
237/// until that runs; an app with no advertising plugins publishes an
238/// empty vec.
239static API_ENDPOINTS: OnceLock<Vec<crate::plugin::ApiEndpoint>> = OnceLock::new();
240
241/// Publish the collected `Plugin::api_endpoints()`. Called once by
242/// `App::build()` after walking every registered plugin.
243pub(crate) fn init_api_endpoints(endpoints: Vec<crate::plugin::ApiEndpoint>) {
244    let _ = API_ENDPOINTS.set(endpoints);
245}
246
247/// Every callable endpoint registered plugins advertised for service
248/// discovery, in plugin-registration order. Empty until `App::build()`
249/// has run. A REST API root (or any discovery surface) reads this to
250/// list plugin endpoints without depending on those plugins' crates.
251pub fn registered_api_endpoints() -> Vec<crate::plugin::ApiEndpoint> {
252    API_ENDPOINTS.get().cloned().unwrap_or_default()
253}
254
255/// Publish the per-model alias routing. Called by `App::build()`
256/// during phase 3 after walking every plugin's `Plugin::database()`.
257/// Plugins that returned `None` contribute no entries; only the
258/// explicit overrides land here.
259pub(crate) fn init_model_aliases(map: std::collections::HashMap<String, String>) {
260    MODEL_ALIASES
261        .set(map)
262        .expect("umbral::migrate::init_model_aliases called more than once");
263}
264
265/// Look up the database alias for a SQL table name — the reverse of
266/// the `Model::NAME → alias` lookup that [`model_alias`] does. Walks
267/// the registered model metas to find the one whose `table` matches
268/// (snake_case of the struct name + any `#[umbral(table = "...")]`
269/// override) and returns its alias if set. Falls back to `"default"`
270/// when no model owns the table (e.g. orphan schema, the
271/// `umbral_migrations` table itself) — those land on the main pool.
272///
273/// Used by the migration engine's per-DB dispatch in [`run_in`] to
274/// route each operation to the right pool.
275pub fn table_alias(table_name: &str) -> String {
276    for meta in registered_models() {
277        if meta.table == table_name {
278            return model_alias(&meta.name).unwrap_or_else(|| "default".to_string());
279        }
280    }
281    "default".to_string()
282}
283
284/// Look up the database alias for one model. Returns `None` if the
285/// model isn't routed explicitly (the caller falls back to the
286/// `"default"` pool); returns `None` even when the alias map hasn't
287/// been initialised so low-level tests that drive `init_plugins`
288/// directly don't have to wire a second call.
289pub fn model_alias(model_name: &str) -> Option<String> {
290    MODEL_ALIASES.get()?.get(model_name).cloned()
291}
292
293static MODEL_META_BY_NAME: OnceLock<std::collections::HashMap<String, ModelMeta>> = OnceLock::new();
294
295/// Cached `&ModelMeta` lookup by model name. Returns `None` before
296/// `App::build` populates the registry (low-level tests), which the routing
297/// seam treats as "fall back to legacy static routing".
298pub fn model_meta_ref(name: &str) -> Option<&'static ModelMeta> {
299    if !is_initialised() {
300        return None;
301    }
302    MODEL_META_BY_NAME
303        .get_or_init(|| {
304            registered_models()
305                .into_iter()
306                .map(|m| (m.name.clone(), m))
307                .collect()
308        })
309        .get(name)
310}
311
312/// Return the models registered against a specific plugin. Empty if
313/// no plugin by that name registered models.
314pub fn models_for_plugin(plugin: &str) -> Vec<ModelMeta> {
315    REGISTRY
316        .get()
317        .expect("umbral: model registry not initialised — did you call App::build()?")
318        .iter()
319        .filter(|(p, _)| p == plugin)
320        .map(|(_, m)| m.clone())
321        .collect()
322}
323
324/// Static metadata for one registered model, copied off the `Model`
325/// trait's `const`s when the user calls `App::builder().model::<T>()`.
326///
327/// Owned (no lifetimes) so the registry can hold an arbitrary number
328/// without the lifetime contortions a slice of trait references would
329/// need. The cost is one Vec at `App::build` time; the win is
330/// `registered_models()` having a plain `&'static [ModelMeta]` signature.
331#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
332pub struct ModelMeta {
333    /// The struct name (`Model::NAME`). Identifies the model across
334    /// snapshot diffs even if the table is renamed.
335    pub name: String,
336    /// The SQL table name (`Model::TABLE`).
337    pub table: String,
338    /// One owned column descriptor per field, in declaration order.
339    /// Owned (`Column`, not the underlying static `FieldSpec`) so the
340    /// snapshot round-trips cleanly through serde.
341    pub fields: Vec<Column>,
342    /// Human-readable display name from `Model::DISPLAY`. Defaults to
343    /// `Model::NAME` when no `#[umbral(display = "...")]` is present.
344    #[serde(default)]
345    pub display: String,
346    /// Lucide icon slug from `Model::ICON`. Defaults to `"database"`.
347    #[serde(default = "default_icon")]
348    pub icon: String,
349    /// Database alias from `Model::DATABASE`, when set. `None` means
350    /// "fall back to the owning plugin's `Plugin::database()`, then
351    /// the `default` pool." Captured here so `App::build`'s alias
352    /// routing can read it without re-reaching into the trait at a
353    /// later phase.
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub database: Option<String>,
356    /// Mirrors `Model::SINGLETON`. Closes BUG-9 in
357    /// `bugs/tests/testBugs.md`. Default `false`; admin renderers
358    /// read it to auto-redirect list-view to the edit form.
359    #[serde(default, skip_serializing_if = "is_false")]
360    pub singleton: bool,
361    /// Mirrors `Model::UNIQUE_TOGETHER`. Composite-UNIQUE constraints,
362    /// each inner `Vec<String>` listing the columns of one constraint.
363    /// Closes BUG-6.
364    #[serde(default, skip_serializing_if = "Vec::is_empty")]
365    pub unique_together: Vec<Vec<String>>,
366    /// Mirrors `Model::INDEXES`. Each inner `Vec<String>` lists the
367    /// columns of one multi-column index. Closes BUG-7.
368    #[serde(default, skip_serializing_if = "Vec::is_empty")]
369    pub indexes: Vec<Vec<String>>,
370    /// Mirrors `Model::ORDERING`. Each tuple is `(column, descending)`
371    /// — `descending == true` lowers to `ORDER BY col DESC`. Closes
372    /// BUG-8.
373    #[serde(default, skip_serializing_if = "Vec::is_empty")]
374    pub ordering: Vec<(String, bool)>,
375    /// Mirrors `Model::M2M_RELATIONS`. Many-to-many relations declared
376    /// on this model. The migration engine uses this to auto-generate
377    /// junction tables. Closes BUG-16.
378    #[serde(default, skip_serializing_if = "Vec::is_empty")]
379    pub m2m_relations: Vec<M2MRelation>,
380    /// Mirrors `Model::SOFT_DELETE` (`#[umbral(soft_delete)]`). The
381    /// dynamic / annotate paths read this to auto-exclude
382    /// `deleted_at IS NULL` children from correlated counts and to
383    /// drive trash-aware admin views without re-reaching into the
384    /// typed trait. Shared enabler for gaps2 #35 + #39a.
385    #[serde(default, skip_serializing_if = "is_false")]
386    pub soft_delete: bool,
387    /// The app label (the owning plugin's name), mirrors `Model::APP_LABEL`.
388    /// Sourced from `#[umbral(plugin = "...")]`; `"app"` when absent.
389    /// Authoritative for permission codenames (gaps2 #80g): replaces the
390    /// old table-name-split heuristic that collided distinct models. The
391    /// `#[serde(default)]` keeps pre-#80g snapshot JSON round-tripping.
392    #[serde(default = "default_app_label")]
393    pub app_label: String,
394}
395
396fn default_app_label() -> String {
397    "app".to_string()
398}
399
400impl Default for ModelMeta {
401    fn default() -> Self {
402        Self {
403            name: String::new(),
404            table: String::new(),
405            fields: Vec::new(),
406            display: String::new(),
407            icon: default_icon(),
408            database: None,
409            singleton: false,
410            unique_together: Vec::new(),
411            indexes: Vec::new(),
412            ordering: Vec::new(),
413            m2m_relations: Vec::new(),
414            soft_delete: false,
415            app_label: default_app_label(),
416        }
417    }
418}
419
420/// Owned mirror of `orm::M2MRelationSpec` so `ModelMeta` can be
421/// serialised into migration JSON without lifetimes.
422#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
423pub struct M2MRelation {
424    pub field_name: String,
425    pub target_table: String,
426    pub target_name: String,
427}
428
429fn default_icon() -> String {
430    "database".to_string()
431}
432
433/// Serde default for [`Operation::CreateM2MTable`]'s `parent_ty` /
434/// `child_ty` fields. Older snapshot files (pre-phase-2) had no
435/// per-side PK type and assumed `BigInt` on both ends — this keeps
436/// them round-tripping without rewrites.
437fn default_bigint() -> crate::orm::SqlType {
438    crate::orm::SqlType::BigInt
439}
440
441impl ModelMeta {
442    /// The primary-key column on this model. Every umbral model
443    /// has exactly one PK by construction (the derive enforces
444    /// it), but the lookup is `Option`-shaped because nothing
445    /// stops a hand-written `ModelMeta` (test fixtures, etc.)
446    /// from omitting it.
447    pub fn pk_column(&self) -> Option<&Column> {
448        self.fields.iter().find(|c| c.primary_key)
449    }
450
451    /// Read static metadata off `T: Model` into an owned `ModelMeta`.
452    /// Called from `AppBuilder::model::<T>()`.
453    pub fn for_<T: Model>() -> Self {
454        Self {
455            name: T::NAME.to_string(),
456            table: T::TABLE.to_string(),
457            fields: T::FIELDS.iter().map(Column::from).collect(),
458            display: T::DISPLAY.to_string(),
459            icon: T::ICON.to_string(),
460            database: T::DATABASE.map(|s| s.to_string()),
461            singleton: T::SINGLETON,
462            unique_together: T::UNIQUE_TOGETHER
463                .iter()
464                .map(|group| group.iter().map(|s| s.to_string()).collect())
465                .collect(),
466            indexes: T::INDEXES
467                .iter()
468                .map(|group| group.iter().map(|s| s.to_string()).collect())
469                .collect(),
470            ordering: T::ORDERING
471                .iter()
472                .map(|(col, desc)| (col.to_string(), *desc))
473                .collect(),
474            m2m_relations: T::M2M_RELATIONS
475                .iter()
476                .map(|r| M2MRelation {
477                    field_name: r.field_name.to_string(),
478                    target_table: r.target_table.to_string(),
479                    target_name: r.target_name.to_string(),
480                })
481                .collect(),
482            soft_delete: T::SOFT_DELETE,
483            app_label: T::APP_LABEL.to_string(),
484        }
485    }
486}
487
488/// A snapshot of every registered model at a point in time.
489///
490/// Serialised into the `snapshot_after` field of a migration file so
491/// future `makemigrations` runs can diff against it without replaying
492/// every prior migration's operations.
493#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
494pub struct Snapshot {
495    /// Models sorted by name so the JSON is deterministic and the
496    /// snapshot_hash is stable across runs that produce equivalent
497    /// content.
498    pub models: Vec<ModelMeta>,
499}
500
501impl Snapshot {
502    /// Build a snapshot from the live registry (the current state of
503    /// the application's models, post-`App::build`).
504    pub fn current() -> Self {
505        let mut models = registered_models().to_vec();
506        models.sort_by(|a, b| a.name.cmp(&b.name));
507        Self { models }
508    }
509
510    /// Build a snapshot containing only the models registered
511    /// against the given plugin. Used by `make_in` to diff each
512    /// plugin's migrations independently against its own prior
513    /// snapshot, so cross-plugin model sets don't bleed into one
514    /// migration file.
515    pub fn current_for(plugin: &str) -> Self {
516        let mut models = models_for_plugin(plugin);
517        models.sort_by(|a, b| a.name.cmp(&b.name));
518        Self { models }
519    }
520
521    /// Compute the snapshot's SHA-256 hash, hex-encoded. Stored in the
522    /// `umbral_migrations.snapshot_hash` column for drift detection.
523    pub fn hash(&self) -> String {
524        use sha2::{Digest, Sha256};
525        let json = serde_json::to_string(self).expect("Snapshot serializes");
526        let digest = Sha256::digest(json.as_bytes());
527        hex(&digest[..])
528    }
529}
530
531fn hex(bytes: &[u8]) -> String {
532    const HEX: &[u8; 16] = b"0123456789abcdef";
533    let mut s = String::with_capacity(bytes.len() * 2);
534    for b in bytes {
535        s.push(HEX[(b >> 4) as usize] as char);
536        s.push(HEX[(b & 0x0f) as usize] as char);
537    }
538    s
539}
540
541/// One operation inside a migration. The migration engine renders each
542/// operation to SQL via the active backend (M4 `DatabaseBackend::
543/// map_type`) and runs them in declaration order inside one
544/// transaction per migration file.
545///
546/// M5 v1 shipped table-level ops; M8 v1 adds `AddColumn` and
547/// `DropColumn`. `AlterColumn`, index / constraint ops, and
548/// `RunSql` / `RunCode` are deferred (see `docs/specs/06-migration-
549/// engine.md`).
550#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
551#[serde(tag = "kind")]
552pub enum Operation {
553    /// Create a new table. `columns` is in declaration order; the
554    /// engine builds a sea-query `Table::create()` over them and runs
555    /// the rendered DDL. `unique_together` lowers to inline
556    /// `UNIQUE (col1, col2)` clauses; `indexes` lowers to follow-up
557    /// `CREATE INDEX` statements after the table is created. Both
558    /// default to empty for backward-compat with older snapshots.
559    CreateTable {
560        table: String,
561        columns: Vec<Column>,
562        #[serde(default, skip_serializing_if = "Vec::is_empty")]
563        unique_together: Vec<Vec<String>>,
564        #[serde(default, skip_serializing_if = "Vec::is_empty")]
565        indexes: Vec<Vec<String>>,
566    },
567    /// Drop an existing table.
568    DropTable { table: String },
569    /// Add a new column to an existing table. Rendered as
570    /// `ALTER TABLE x ADD COLUMN y TYPE [NOT NULL]`. SQLite refuses a
571    /// non-nullable add against a populated table without a default;
572    /// the engine surfaces that as a sqlx error at apply time (M8 v1).
573    /// A future op `AddColumnWithDefault` lifts the restriction once
574    /// the `#[umbral(default = ...)]` attribute lands.
575    AddColumn { table: String, column: Column },
576    /// Drop a column from an existing table. Rendered as
577    /// `ALTER TABLE x DROP COLUMN y`. SQLite 3.35+ and Postgres
578    /// support this natively; older SQLite would need a table-
579    /// recreation dance the engine doesn't implement.
580    DropColumn { table: String, column: String },
581    /// Alter a column's nullable flag (the only safe in-place change
582    /// the engine ships at M5.1). Self-contained: carries the full
583    /// new column list so the SQLite table-recreation dance can
584    /// rebuild the schema without re-reading the snapshot. The
585    /// `column` field names the specific column that triggered the
586    /// alter (used for the filename suffix and diagnostics); the
587    /// `new_columns` list is the post-change schema.
588    AlterColumn {
589        table: String,
590        column: String,
591        new_columns: Vec<Column>,
592        /// The table's composite UNIQUE groups (audit_2 core-migrate #10). The
593        /// SQLite recreation dance rebuilds the table from scratch, so it must
594        /// re-emit these — otherwise a nullable-flip / safe-cast alter silently
595        /// DROPS every composite UNIQUE constraint (duplicates become
596        /// insertable — integrity loss). Postgres alters in place and ignores
597        /// this. `serde(default)` keeps older on-disk migrations deserialising.
598        #[serde(default, skip_serializing_if = "Vec::is_empty")]
599        unique_together: Vec<Vec<String>>,
600        /// The table's composite (multi-column) index groups (audit_2
601        /// core-migrate #10). Re-created by the SQLite dance after the rebuild,
602        /// for the same reason as `unique_together`. Single-column / FK /
603        /// soft-delete indexes are re-derived from `new_columns`.
604        #[serde(default, skip_serializing_if = "Vec::is_empty")]
605        indexes: Vec<Vec<String>>,
606        /// Snapshot of the table's columns *before* this alter. Carried
607        /// so the Postgres renderer can decide per-column whether it
608        /// needs a TYPE/USING clause vs a SET/DROP NOT NULL — without
609        /// re-walking the snapshot file. `Option` + `serde(default)`
610        /// keeps older on-disk migrations deserialising cleanly; ops
611        /// produced before this field existed get `None` and fall back
612        /// to the legacy nullable-only Postgres path.
613        #[serde(default, skip_serializing_if = "Option::is_none")]
614        prev_columns: Option<Vec<Column>>,
615    },
616    /// Rename an existing table. Emitted by `diff` when a model's table
617    /// name changes but its `Model::NAME` (the Rust struct name) stays
618    /// the same (first-pass detection), or when the column shapes are
619    /// bit-identical and the struct name changed too (second-pass
620    /// heuristic detection). Both SQLite and Postgres render as
621    /// `ALTER TABLE "<from>" RENAME TO "<to>"`.
622    ///
623    /// The migration tracking table records `(plugin, name)` of each
624    /// applied migration — it is not affected by a table rename inside
625    /// the migration.
626    RenameTable { from: String, to: String },
627    /// Create a many-to-many junction table. Auto-emitted when a model
628    /// gains an `M2M<T>` field. Closes BUG-16 phase 2.
629    ///
630    /// The junction table name is `parent_table_field_name`. Columns:
631    /// `parent_id` (FK to parent), `child_id` (FK to target), both with
632    /// `ON DELETE CASCADE`. Composite PK `(parent_id, child_id)`.
633    ///
634    /// `parent_ty` and `child_ty` carry the SQL types of the
635    /// referenced PK columns — `BigInt` for an `i64` PK, `Text` for a
636    /// `String` slug, `Uuid` for a `uuid::Uuid`. The renderer maps
637    /// these to the right column type per backend; without them the
638    /// junction's `child_id INTEGER` would reject a string codename
639    /// at insert time. `#[serde(default)]` keeps older snapshot files
640    /// (pre-phase-2) round-tripping — they default to `BigInt`,
641    /// matching the original i64-only behaviour.
642    CreateM2MTable {
643        junction_table: String,
644        parent_table: String,
645        parent_col: String,
646        child_table: String,
647        child_col: String,
648        #[serde(default = "default_bigint")]
649        parent_ty: crate::orm::SqlType,
650        #[serde(default = "default_bigint")]
651        child_ty: crate::orm::SqlType,
652    },
653    /// Drop a many-to-many junction table. Auto-emitted when an `M2M<T>`
654    /// field is removed from a model.
655    DropM2MTable { junction_table: String },
656    /// Gap 88: rename a column on an existing table. Emitted by the
657    /// diff engine when a single column with one shape was dropped
658    /// and one with the same shape was added in the same diff —
659    /// the heuristic match for "the user renamed `title` to
660    /// `headline`." Both SQLite (3.25+) and Postgres render as
661    /// `ALTER TABLE "<t>" RENAME COLUMN "<from>" TO "<to>"`.
662    ///
663    /// `column` carries the post-rename column shape so the
664    /// snapshot stays in sync. The migration only renames; never
665    /// alters other column attributes — a rename combined with a
666    /// type change emits a RenameColumn AND a follow-on AlterColumn
667    /// against the new name.
668    RenameColumn {
669        table: String,
670        from: String,
671        to: String,
672        #[serde(default, skip_serializing_if = "Option::is_none")]
673        column: Option<Column>,
674    },
675    /// Gap #69: a raw-SQL **data** migration. Unlike every other
676    /// variant it changes *rows*, not the schema model — so the
677    /// autodetector NEVER emits it (it has no model-state effect), and
678    /// a migration carrying only `RunSql` ops has
679    /// `snapshot_after == snapshot_before`. It is always hand-authored:
680    /// generate an empty migration with `makemigrations --empty
681    /// <plugin>`, then add the `RunSql` op by editing the file.
682    ///
683    /// `sql` is the forward statement(s), executed verbatim on the
684    /// per-migration transaction — same string on both backends (raw
685    /// SQL the renderer passes through untouched), so the author owns
686    /// portability. `reverse_sql` is the optional un-apply statement
687    /// (used by a future `migrate --reverse`); `None` means
688    /// irreversible.
689    ///
690    /// Under schema-per-tenant the op runs **per tenant schema** (the
691    /// schema-migrate loop applies every op under the
692    /// `<schema>, public` search_path), so a tenant-app `RunSql` writes
693    /// tenant rows while reading shared `public` lookup tables — the
694    /// boundary-spanning data migration. A shared-app `RunSql` runs once
695    /// in `public` via the normal `migrate`.
696    RunSql {
697        sql: String,
698        #[serde(default, skip_serializing_if = "Option::is_none")]
699        reverse_sql: Option<String>,
700    },
701    /// Create a composite index, or a composite UNIQUE constraint, on an
702    /// EXISTING table. Emitted by `diff` when a model gains a
703    /// `unique_together` group or a multi-column `indexes` entry with no
704    /// accompanying column change — a case that previously produced NO
705    /// migration at all, so the constraint was silently never created.
706    ///
707    /// `unique` selects `CREATE UNIQUE INDEX` (a `unique_together` group)
708    /// vs a plain `CREATE INDEX` (an `indexes` group). The index NAME is
709    /// deterministic — `uniq_<table>_<cols>` when unique, `idx_<table>_<cols>`
710    /// otherwise — so the matching [`DropIndex`](Operation::DropIndex) can
711    /// name it. Rendered `IF NOT EXISTS` on both backends, so it is a safe
712    /// no-op when a same-migration `AlterColumn` already rebuilt the table
713    /// with the constraint (the SQLite dance) — the two never conflict.
714    AddIndex {
715        table: String,
716        columns: Vec<String>,
717        #[serde(default)]
718        unique: bool,
719    },
720    /// Drop a composite index / UNIQUE constraint previously created by an
721    /// [`AddIndex`](Operation::AddIndex), or by a `CreateTable` that renders
722    /// its `unique_together`/`indexes` as the same deterministically-named
723    /// indexes. Emitted by `diff` when a model LOSES a `unique_together`
724    /// group or `indexes` entry. Rendered `DROP INDEX IF EXISTS <name>` on
725    /// both backends (the name is recomputed from `table` + `columns` +
726    /// `unique`, matching `AddIndex`).
727    DropIndex {
728        table: String,
729        columns: Vec<String>,
730        #[serde(default)]
731        unique: bool,
732    },
733}
734
735impl Operation {
736    /// The primary table this operation targets. For `RenameTable`,
737    /// returns the source name (the post-rename `to` lives in the new
738    /// snapshot, but routing decisions look up the model meta by its
739    /// pre-rename `from`).
740    ///
741    /// Used by `run_in`'s per-DB dispatch loop to route each op to the
742    /// pool where its table actually lives.
743    pub fn table_name(&self) -> &str {
744        match self {
745            Operation::CreateTable { table, .. }
746            | Operation::DropTable { table }
747            | Operation::AddColumn { table, .. }
748            | Operation::DropColumn { table, .. }
749            | Operation::AlterColumn { table, .. }
750            | Operation::RenameColumn { table, .. }
751            | Operation::AddIndex { table, .. }
752            | Operation::DropIndex { table, .. } => table,
753            Operation::RenameTable { from, .. } => from,
754            Operation::CreateM2MTable { junction_table, .. }
755            | Operation::DropM2MTable { junction_table } => junction_table,
756            // A data migration targets no single table. The empty name
757            // routes it to the `"default"` alias via `table_alias`'s
758            // fallback (see `op_targets_alias`).
759            Operation::RunSql { .. } => "",
760        }
761    }
762}
763
764/// One column inside a [`Operation::CreateTable`].
765///
766/// Mirrors the structure of [`FieldSpec`] but is fully owned for
767/// serialisation. Reconstructed from a `FieldSpec` at diff time.
768#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
769pub struct Column {
770    pub name: String,
771    pub ty: SqlType,
772    pub primary_key: bool,
773    pub nullable: bool,
774    /// For `SqlType::ForeignKey` columns: the SQL table name of the
775    /// referenced model. `None` for all non-FK columns.
776    #[serde(default, skip_serializing_if = "Option::is_none")]
777    pub fk_target: Option<String>,
778    /// When `true`, this field is never shown on any admin form (create or
779    /// edit). Propagated from `FieldSpec::noform`.
780    #[serde(default)]
781    pub noform: bool,
782    /// When `true`, this field is a privileged/server-managed column that the
783    /// untrusted JSON write path (`insert_json`/`update_json`, i.e. REST
784    /// create/update + admin form-submit) strips UNLESS the caller explicitly
785    /// authorizes it via [`DynQuerySet::allow_privileged`]. Propagated from
786    /// `FieldSpec::privileged`. This is the default-DENY guard for mass
787    /// assignment of fields like `is_superuser`/`is_staff`/ownership FKs
788    /// (audit_2 H3): they can't be set by an unprivileged writer even if the
789    /// model exposes them on its create/update surface. Purely a write-auth
790    /// concern — never part of the schema shape, so it is skipped from
791    /// migration snapshots when at its `false` default.
792    #[serde(default, skip_serializing_if = "is_false")]
793    pub privileged: bool,
794    /// For FK columns: whether to emit a physical `FOREIGN KEY ...
795    /// REFERENCES` constraint. Propagated from `FieldSpec::db_constraint`.
796    /// `false` (set via `#[umbral(db_constraint = false)]`) keeps the
797    /// logical FK (column + `fk_target`) but renders no `REFERENCES`
798    /// clause — the only valid shape for a cross-database FK. Closes
799    /// gaps2 #22. Defaults to `true` so existing migration JSON
800    /// round-trips unchanged (omitted from JSON when at its default).
801    #[serde(default = "default_true", skip_serializing_if = "is_true")]
802    pub db_constraint: bool,
803    /// When `true`, this field appears on the edit form as read-only.
804    /// Propagated from `FieldSpec::noedit`.
805    #[serde(default)]
806    pub noedit: bool,
807    /// Display-string marker — propagated from
808    /// `FieldSpec::is_string_repr`. The admin uses the first column
809    /// with this flag as the default `list_display` label when no
810    /// explicit one is configured.
811    #[serde(default)]
812    pub is_string_repr: bool,
813    /// Display truncation cap — propagated from `FieldSpec::max_length`.
814    /// `0` means no truncation.
815    #[serde(default)]
816    pub max_length: u32,
817    /// Closed-set DB values for a choices column. Propagated from
818    /// `FieldSpec::choices`. Non-empty when the model field carries
819    /// `#[umbral(choices)]`; the migration engine emits a Postgres
820    /// `CHECK (col IN (...))` constraint when this slice is non-empty.
821    /// Empty for every non-choices column.
822    #[serde(default, skip_serializing_if = "Vec::is_empty")]
823    pub choices: Vec<String>,
824    /// Human labels matching `choices` position-for-position. Carried
825    /// alongside `choices` so the admin's `<select>` widget has labels
826    /// without the runtime needing to reflect on the model type.
827    #[serde(default, skip_serializing_if = "Vec::is_empty")]
828    pub choice_labels: Vec<String>,
829    /// SQL `DEFAULT` value — propagated from `FieldSpec::default`.
830    /// Empty string means no default. The migration engine reads this
831    /// at DDL-emit time for both `CREATE TABLE` and `ALTER TABLE ADD
832    /// COLUMN`. Set via `#[umbral(default = "...")]` on the model field.
833    #[serde(default, skip_serializing_if = "String::is_empty")]
834    pub default: String,
835    /// Distinguishes a multi-valued [`MultiChoice<E>`] column from a
836    /// single-valued choices column. Both share `ty: Text` plus the same
837    /// `choices` / `choice_labels` metadata; this flag is the only
838    /// signal that the value is a CSV. Empty / false for every other
839    /// column.
840    ///
841    /// [`MultiChoice<E>`]: crate::orm::MultiChoice
842    #[serde(default, skip_serializing_if = "is_false")]
843    pub is_multichoice: bool,
844
845    /// Carries `FieldSpec::unique` into the migration snapshot. The
846    /// DDL builders emit a `UNIQUE` clause on this column at
847    /// `CREATE TABLE` time when set. Default `false` keeps existing
848    /// migration JSON files round-tripping unchanged (the field is
849    /// omitted on serialise when default).
850    #[serde(default, skip_serializing_if = "is_false")]
851    pub unique: bool,
852
853    /// Carries `FieldSpec::on_delete` into the migration snapshot.
854    /// FK columns only — the DDL builders emit
855    /// `ON DELETE <action>` when this is anything other than
856    /// `NoAction`. Default `NoAction` is omitted from JSON so
857    /// existing migration files round-trip without churn.
858    #[serde(default, skip_serializing_if = "is_no_action")]
859    pub on_delete: crate::orm::FkAction,
860
861    /// Carries `FieldSpec::on_update` into the migration snapshot.
862    /// Same shape as `on_delete`; emits `ON UPDATE <action>`.
863    #[serde(default, skip_serializing_if = "is_no_action")]
864    pub on_update: crate::orm::FkAction,
865
866    /// Carries `FieldSpec::index` into the migration snapshot. The
867    /// CreateTable + AddColumn render paths emit a matching
868    /// `CREATE INDEX idx_<table>_<col>` for every column whose
869    /// flag is set. Default `false` keeps existing migration JSON
870    /// round-tripping unchanged.
871    #[serde(default, skip_serializing_if = "is_false")]
872    pub index: bool,
873
874    /// Carries `FieldSpec::auto_now_add` into the migration
875    /// snapshot. The dynamic write path (`DynQuerySet::insert_json`)
876    /// auto-populates the column with `Utc::now()` when the body
877    /// omits it. Default `false` so existing migration JSON
878    /// round-trips unchanged.
879    #[serde(default, skip_serializing_if = "is_false")]
880    pub auto_now_add: bool,
881
882    /// Carries `FieldSpec::auto_now` into the migration snapshot.
883    /// Same shape as `auto_now_add` but fires on update too.
884    #[serde(default, skip_serializing_if = "is_false")]
885    pub auto_now: bool,
886
887    /// Carries `FieldSpec::trim` into the snapshot. Behavioral (dynamic write
888    /// path strips surrounding whitespace), not schema-affecting — excluded
889    /// from the schema diff like `auto_now`. Default `false` so existing
890    /// migration JSON round-trips unchanged.
891    #[serde(default, skip_serializing_if = "is_false")]
892    pub trim: bool,
893
894    /// Carries `FieldSpec::lowercase` into the snapshot. Behavioral (dynamic
895    /// write path lowercases the value), not schema-affecting. Default `false`
896    /// so existing migration JSON round-trips unchanged.
897    #[serde(default, skip_serializing_if = "is_false")]
898    pub lowercase: bool,
899
900    /// Carries `FieldSpec::case_insensitive` into the snapshot. Schema-affecting
901    /// (Postgres `citext` / SQLite `COLLATE NOCASE`), but — like `unique` — the
902    /// diff comparator (`column_shape`) does NOT watch it, so it applies at
903    /// CREATE TABLE and toggling it on a live column needs a hand-written
904    /// migration. Default `false` so existing migration JSON round-trips.
905    #[serde(default, skip_serializing_if = "is_false")]
906    pub case_insensitive: bool,
907
908    /// Carries `FieldSpec::help` into the migration snapshot.
909    /// Default empty string is omitted from JSON so existing
910    /// migration files round-trip unchanged.
911    #[serde(default, skip_serializing_if = "String::is_empty")]
912    pub help: String,
913
914    /// Carries `FieldSpec::example` into the migration snapshot.
915    /// Same shape as `help`.
916    #[serde(default, skip_serializing_if = "String::is_empty")]
917    pub example: String,
918
919    /// Carries `FieldSpec::widget` into the migration snapshot — the
920    /// form-renderer presentation hint (features.md #4). Presentation
921    /// only, no DB effect, so it's excluded from the schema diff the
922    /// same way `help` / `example` are. `None` is omitted from JSON so
923    /// existing migration files round-trip unchanged.
924    #[serde(default, skip_serializing_if = "Option::is_none")]
925    pub widget: Option<String>,
926
927    /// Carries `FieldSpec::supported_backends` into the migration
928    /// snapshot. When non-empty, the boot system check rejects the
929    /// model on any backend not listed. Closes IMP-5 from
930    /// `bugs/tests/testBugs.md`. Default empty (works on every
931    /// backend); JSON skip-when-empty so existing migration files
932    /// don't churn.
933    #[serde(default, skip_serializing_if = "Vec::is_empty")]
934    pub supported_backends: Vec<String>,
935
936    /// IMP-3: numeric lower bound. `None` means "no minimum"; the
937    /// DDL emits a `CHECK (col >= N)` constraint when set.
938    #[serde(default, skip_serializing_if = "Option::is_none")]
939    pub min: Option<i64>,
940
941    /// IMP-3: numeric upper bound. Same shape as `min`.
942    #[serde(default, skip_serializing_if = "Option::is_none")]
943    pub max: Option<i64>,
944
945    /// BUG-11/12/13: constrained-text marker. `None` is plain text;
946    /// `Some("slug" | "email" | "url")` flags the column as a
947    /// `Slug` / `Email` / `Url` wrapper. OpenAPI emits the
948    /// corresponding `format` / `pattern`; the REST plugin
949    /// pre-validates the body via `validate_text_format`.
950    #[serde(default, skip_serializing_if = "Option::is_none")]
951    pub text_format: Option<String>,
952
953    /// Gap 109: auto-derive source. When `Some("title")`, the slug is
954    /// computed from the row's `title` column at write time if the
955    /// slug column itself is empty / missing on the body. Pure
956    /// runtime behaviour — has no DDL effect, so the diff engine
957    /// ignores changes to this field. `#[serde(default)]` keeps
958    /// older snapshots round-tripping.
959    #[serde(default, skip_serializing_if = "Option::is_none")]
960    pub slug_from: Option<String>,
961}
962
963fn is_no_action(a: &crate::orm::FkAction) -> bool {
964    matches!(a, crate::orm::FkAction::NoAction)
965}
966
967/// Build a portable `CREATE INDEX IF NOT EXISTS idx_<table>_<col>
968/// ON "<table>" ("<col>")` statement. Same DDL on SQLite and
969/// Postgres — both accept `CREATE INDEX IF NOT EXISTS` and the
970/// `idx_<table>_<col>` name convention is unique enough that the
971/// migration engine can re-emit it idempotently on subsequent
972/// applies. Used by [`render_operation_sqlite`] / `_postgres`
973/// after a `CreateTable` or `AddColumn` op whose column carries
974/// the `#[umbral(index)]` flag. Closes BUG-4.
975fn create_index_stmt(table: &str, column: &str) -> String {
976    let t = table.replace('"', "\"\"");
977    let c = column.replace('"', "\"\"");
978    format!(
979        "CREATE INDEX IF NOT EXISTS \"idx_{table}_{column}\" ON \"{t}\" (\"{c}\")",
980        table = table.replace('"', ""),
981        column = column.replace('"', ""),
982    )
983}
984
985/// Build a Postgres `CREATE INDEX ... USING GIN` for a `tsvector`
986/// (`SqlType::FullText`) column (#33). A tsvector column is useless for
987/// search without a GIN index, so the migration engine emits one
988/// automatically for every full-text column — the caller never has to
989/// hand-write it. **Postgres-only**: GIN is Postgres syntax and FullText
990/// columns are system-check-gated to Postgres, so this only ever renders
991/// from `render_operation_postgres`. The `_gin` name suffix keeps it
992/// distinct from any plain index on the same column.
993fn create_gin_index_stmt(table: &str, column: &str) -> String {
994    let t = table.replace('"', "\"\"");
995    let c = column.replace('"', "\"\"");
996    format!(
997        "CREATE INDEX IF NOT EXISTS \"idx_{table}_{column}_gin\" ON \"{t}\" USING GIN (\"{c}\")",
998        table = table.replace('"', ""),
999        column = column.replace('"', ""),
1000    )
1001}
1002
1003/// Multi-column variant of [`create_index_stmt`]. Closes BUG-7.
1004/// Renders `CREATE INDEX IF NOT EXISTS idx_<table>_<col1>_<col2>
1005/// ON "<table>" ("<col1>", "<col2>")`. Both backends accept the
1006/// same form. Empty groups render no statement (defensive — the
1007/// macro layer rejects them before the engine sees them, but the
1008/// helper still returns a no-op SQL string to keep the caller
1009/// simple).
1010fn create_multi_index_stmt(table: &str, columns: &[String]) -> String {
1011    // A plain composite index IS an `AddIndex { unique: false }` render —
1012    // delegate so the NAME (`idx_<table>_<cols>`) is defined in exactly one
1013    // place and a `CreateTable`'s composite index and a later `DropIndex`
1014    // always agree on it.
1015    add_index_stmt(table, columns, false)
1016}
1017
1018/// Deterministic name for a composite index. `unique` selects the `uniq_`
1019/// prefix (a `unique_together` group), otherwise `idx_`. Derived purely
1020/// from the quote-stripped table + column list so an [`Operation::AddIndex`]
1021/// and the later [`Operation::DropIndex`] that reverses it always compute
1022/// the same name. The `uniq_`/`idx_` split means a UNIQUE and a plain index
1023/// on the SAME columns never collide.
1024fn index_name(table: &str, columns: &[String], unique: bool) -> String {
1025    let t = table.replace('"', "");
1026    let suffix = columns
1027        .iter()
1028        .map(|c| c.replace('"', ""))
1029        .collect::<Vec<_>>()
1030        .join("_");
1031    let prefix = if unique { "uniq" } else { "idx" };
1032    format!("{prefix}_{t}_{suffix}")
1033}
1034
1035/// `CREATE [UNIQUE] INDEX IF NOT EXISTS "<name>" ON "<table>" (cols)` —
1036/// identical syntax on SQLite and Postgres. The index NAME is a bare
1037/// identifier (via [`index_name`]); the ON-clause table reference is a
1038/// *quoted* identifier with inner quotes doubled. An empty column list
1039/// renders an empty string (defensive no-op; the macro layer rejects
1040/// empty groups upstream).
1041fn add_index_stmt(table: &str, columns: &[String], unique: bool) -> String {
1042    if columns.is_empty() {
1043        return String::new();
1044    }
1045    let name = index_name(table, columns, unique);
1046    let t_esc = table.replace('"', "\"\"");
1047    let col_list = columns
1048        .iter()
1049        .map(|c| format!("\"{}\"", c.replace('"', "\"\"")))
1050        .collect::<Vec<_>>()
1051        .join(", ");
1052    let unique_kw = if unique { "UNIQUE " } else { "" };
1053    format!("CREATE {unique_kw}INDEX IF NOT EXISTS \"{name}\" ON \"{t_esc}\" ({col_list})")
1054}
1055
1056/// `DROP INDEX IF EXISTS "<name>"` — same on both backends. Postgres
1057/// resolves the unqualified name via the search_path (so a schema-per-tenant
1058/// migrate drops the index inside the active schema).
1059fn drop_index_stmt(name: &str) -> String {
1060    let n = name.replace('"', "\"\"");
1061    format!("DROP INDEX IF EXISTS \"{n}\"")
1062}
1063
1064/// Lower an M2M junction column's PK type into the SQLite column
1065/// declaration string used inside the raw `CREATE TABLE` template.
1066/// SQLite has affinity types: every integer width stores as `INTEGER`
1067/// (one ROWID-aliased column), and TEXT covers `String` / `Uuid`.
1068/// Closes BUG-16 phase 2.
1069fn m2m_pk_sql_type_sqlite(ty: crate::orm::SqlType) -> &'static str {
1070    use crate::orm::SqlType;
1071    match ty {
1072        SqlType::SmallInt | SqlType::Integer | SqlType::BigInt | SqlType::ForeignKey => "INTEGER",
1073        SqlType::Text | SqlType::Uuid => "TEXT",
1074        // The macro-side classifier only sets these for PK columns
1075        // when the user wrote a non-standard PK type. If we ever
1076        // see one here that doesn't make sense as a junction column
1077        // (Boolean, Date, Real, …), TEXT is the safest catch-all
1078        // affinity — SQLite will accept it and the rest of the
1079        // ORM will surface the deeper "this can't be a PK" error
1080        // through the system check.
1081        _ => "TEXT",
1082    }
1083}
1084
1085/// Lower an M2M junction column's PK type into the Postgres column
1086/// declaration string. Postgres is strict about types — `BIGINT` for
1087/// 64-bit integers, `INTEGER` for 32-bit, `SMALLINT` for 16-bit,
1088/// `TEXT` for `String`, `UUID` for `uuid::Uuid`. Mirrors the choices
1089/// `build_column_def_postgres` makes for the same `SqlType` variants.
1090fn m2m_pk_sql_type_postgres(ty: crate::orm::SqlType) -> &'static str {
1091    use crate::orm::SqlType;
1092    match ty {
1093        SqlType::SmallInt => "SMALLINT",
1094        SqlType::Integer => "INTEGER",
1095        SqlType::BigInt | SqlType::ForeignKey => "BIGINT",
1096        SqlType::Text => "TEXT",
1097        SqlType::Uuid => "UUID",
1098        _ => "TEXT",
1099    }
1100}
1101
1102/// Build the ` ON DELETE <action> ON UPDATE <action>` suffix for a
1103/// FK column. Each half is emitted only when its action is anything
1104/// other than `NoAction` — keeps the generated DDL minimal and
1105/// matches the SQL standard's default (NO ACTION when the clause is
1106/// omitted).
1107///
1108/// Closes gap #68. Shared between the SQLite and Postgres builders
1109/// because the REFERENCES tail syntax is identical on both.
1110fn fk_action_suffix(col: &Column) -> String {
1111    let mut s = String::new();
1112    if let Some(kw) = col.on_delete.sql_keyword() {
1113        s.push_str(" ON DELETE ");
1114        s.push_str(kw);
1115    }
1116    if let Some(kw) = col.on_update.sql_keyword() {
1117        s.push_str(" ON UPDATE ");
1118        s.push_str(kw);
1119    }
1120    s
1121}
1122
1123fn is_false(b: &bool) -> bool {
1124    !*b
1125}
1126
1127/// serde default for `Column::db_constraint`: a FK emits its physical
1128/// `REFERENCES` constraint unless the model opts out. Older migration
1129/// JSON predating gaps2 #22 has no `db_constraint` key, so it must
1130/// deserialize as `true` to preserve the historical "always emit"
1131/// behaviour.
1132fn default_true() -> bool {
1133    true
1134}
1135
1136fn is_true(b: &bool) -> bool {
1137    *b
1138}
1139
1140impl From<&FieldSpec> for Column {
1141    fn from(f: &FieldSpec) -> Self {
1142        Self {
1143            name: f.name.to_string(),
1144            ty: f.ty,
1145            primary_key: f.primary_key,
1146            nullable: f.nullable,
1147            fk_target: f.fk_target.map(|s| s.to_string()),
1148            noform: f.noform,
1149            privileged: f.privileged,
1150            db_constraint: f.db_constraint,
1151            noedit: f.noedit,
1152            is_string_repr: f.is_string_repr,
1153            max_length: f.max_length,
1154            choices: f.choices.iter().map(|s| s.to_string()).collect(),
1155            choice_labels: f.choice_labels.iter().map(|s| s.to_string()).collect(),
1156            default: f.default.to_string(),
1157            is_multichoice: f.is_multichoice,
1158            unique: f.unique,
1159            on_delete: f.on_delete,
1160            on_update: f.on_update,
1161            index: f.index,
1162            auto_now_add: f.auto_now_add,
1163            auto_now: f.auto_now,
1164            trim: f.trim,
1165            lowercase: f.lowercase,
1166            case_insensitive: f.case_insensitive,
1167            help: f.help.to_string(),
1168            example: f.example.to_string(),
1169            widget: f.widget.map(|s| s.to_string()),
1170            supported_backends: f.supported_backends.iter().map(|s| s.to_string()).collect(),
1171            min: f.min,
1172            max: f.max,
1173            text_format: f.text_format.map(|s| s.to_string()),
1174            slug_from: f.slug_from.map(|s| s.to_string()),
1175        }
1176    }
1177}
1178
1179/// The on-disk shape of one migration. Files in `migrations/<plugin>/`
1180/// deserialize into this struct.
1181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1182pub struct MigrationFile {
1183    /// Stable id, matches the filename minus `.json`.
1184    pub id: String,
1185    /// The plugin that owns this migration. M5 hardcodes `"app"` for
1186    /// the user's binary; M7 generalises to one directory per plugin.
1187    pub plugin: String,
1188    /// Predecessor migrations, in `(plugin, id)` form. Within-plugin
1189    /// predecessors are implicit (the prior numeric file); cross-
1190    /// plugin predecessors land at M7.
1191    #[serde(default)]
1192    pub depends_on: Vec<MigrationRef>,
1193    /// Ordered operations applied when this migration runs.
1194    pub operations: Vec<Operation>,
1195    /// The full snapshot of every model after this migration has run.
1196    /// Source of truth for the next `make` to diff against.
1197    pub snapshot_after: Snapshot,
1198    /// gaps2 #100: when non-empty, this is a *squash* — it collapses the
1199    /// listed predecessor migrations into one optimized file. The originals
1200    /// are kept on disk (non-destructive) so older deploys still migrate; the
1201    /// runner treats the squash and its replaced set as mutually exclusive
1202    /// (see [`squash_plan`]). Empty for an ordinary migration; `#[serde(default)]`
1203    /// so every pre-squash file on disk deserializes unchanged.
1204    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1205    pub replaces: Vec<MigrationRef>,
1206}
1207
1208/// A pointer to one (plugin, migration_id) pair.
1209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1210pub struct MigrationRef {
1211    pub plugin: String,
1212    pub migration: String,
1213}
1214
1215/// gaps2 #100 — what the runner does with one on-disk migration file, given the
1216/// tracking-table's applied-set. The decision is pure and backend-agnostic; the
1217/// only DB-specific step downstream is *how* a tracking row is written, never
1218/// *whether* the file's operations run.
1219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1220pub enum ApplyDecision {
1221    /// Run the file's operations, then record it in the tracking table. The
1222    /// normal "pending migration" path.
1223    Apply,
1224    /// Do nothing: the migration is already applied, or it's an original that a
1225    /// squash the runner is using has shadowed (running it too would
1226    /// double-apply the schema the squash already built).
1227    Skip,
1228    /// A squash whose entire replaced set is already applied. Its operations
1229    /// would rebuild schema that already exists, so DON'T run them — but insert
1230    /// a tracking row so future runs treat the squash as applied and the now
1231    /// redundant original files can be deleted.
1232    RecordOnly,
1233}
1234
1235/// gaps2 #100 — decide, per on-disk migration file for ONE plugin, whether to
1236/// [`Apply`](ApplyDecision::Apply) / [`Skip`](ApplyDecision::Skip) /
1237/// [`RecordOnly`](ApplyDecision::RecordOnly) it, honoring squash `replaces`.
1238///
1239/// `files` is every migration file for the plugin (the returned Vec is in the
1240/// same order). `applied` is the `(plugin, id)` set from the tracking table.
1241/// The function is pure: no DB, no IO — so both the SQLite and Postgres apply
1242/// loops route through it and inherit identical squash semantics.
1243///
1244/// For a squash `S` replacing set `R`:
1245/// - **all of `R` applied** → `S` is `RecordOnly` (or `Skip` if `S` itself is
1246///   already recorded); every member of `R` is `Skip` (already applied).
1247/// - **none of `R` applied** → `S` is `Apply` (or `Skip` if already recorded);
1248///   every member of `R` is `Skip` (the squash builds their schema).
1249/// - **some of `R` applied** (an interrupted transition) → fall back to the
1250///   individual originals if they are ALL still on disk: `S` is `Skip` and each
1251///   unapplied member of `R` applies as usual. If any original is gone, the
1252///   history can't be reconciled → [`MigrateError::SquashInconsistent`].
1253///
1254/// A plain migration (empty `replaces`) is `Skip` if applied or shadowed by an
1255/// active squash, else `Apply`.
1256fn squash_plan(
1257    files: &[MigrationFile],
1258    applied: &std::collections::HashSet<(String, String)>,
1259) -> Result<Vec<ApplyDecision>, MigrateError> {
1260    use std::collections::HashSet;
1261
1262    let is_applied =
1263        |plugin: &str, id: &str| applied.contains(&(plugin.to_string(), id.to_string()));
1264    let on_disk: HashSet<&str> = files.iter().map(|f| f.id.as_str()).collect();
1265
1266    let mut decisions = vec![ApplyDecision::Apply; files.len()];
1267    // Ids covered by a squash the runner is USING (fully-applied or fully-
1268    // unapplied). Their originals must be skipped so they don't double-apply.
1269    let mut shadowed: HashSet<String> = HashSet::new();
1270
1271    // First pass: classify every squash and collect the shadow set.
1272    for (i, f) in files.iter().enumerate() {
1273        if f.replaces.is_empty() {
1274            continue;
1275        }
1276        let applied_in_r = f
1277            .replaces
1278            .iter()
1279            .filter(|m| is_applied(&m.plugin, &m.migration))
1280            .count();
1281        let self_applied = is_applied(&f.plugin, &f.id);
1282
1283        if applied_in_r == f.replaces.len() {
1284            // Whole history already applied: adopt the squash without running it.
1285            decisions[i] = if self_applied {
1286                ApplyDecision::Skip
1287            } else {
1288                ApplyDecision::RecordOnly
1289            };
1290            for m in &f.replaces {
1291                shadowed.insert(m.migration.clone());
1292            }
1293        } else if applied_in_r == 0 {
1294            // Fresh: the squash builds the whole schema in one shot.
1295            decisions[i] = if self_applied {
1296                ApplyDecision::Skip
1297            } else {
1298                ApplyDecision::Apply
1299            };
1300            for m in &f.replaces {
1301                shadowed.insert(m.migration.clone());
1302            }
1303        } else {
1304            // Partial transition: prefer the originals if they all survive.
1305            let missing: Vec<String> = f
1306                .replaces
1307                .iter()
1308                .filter(|m| !on_disk.contains(m.migration.as_str()))
1309                .map(|m| format!("{}/{}", m.plugin, m.migration))
1310                .collect();
1311            if missing.is_empty() {
1312                // Inactive squash: skip it, let the originals run individually.
1313                decisions[i] = ApplyDecision::Skip;
1314            } else {
1315                return Err(MigrateError::SquashInconsistent {
1316                    plugin: f.plugin.clone(),
1317                    squash: f.id.clone(),
1318                    missing,
1319                });
1320            }
1321        }
1322    }
1323
1324    // Second pass: plain migrations (applied or shadowed → Skip, else Apply).
1325    for (i, f) in files.iter().enumerate() {
1326        if !f.replaces.is_empty() {
1327            continue;
1328        }
1329        if shadowed.contains(&f.id) || is_applied(&f.plugin, &f.id) {
1330            decisions[i] = ApplyDecision::Skip;
1331        }
1332    }
1333
1334    // A squash can itself be shadowed by a larger squash (squash-of-squashes):
1335    // an active outer squash covers it, so it must not run either.
1336    for (i, f) in files.iter().enumerate() {
1337        if shadowed.contains(&f.id) {
1338            decisions[i] = ApplyDecision::Skip;
1339        }
1340    }
1341
1342    Ok(decisions)
1343}
1344
1345/// At M5 every migration belongs to a single placeholder plugin. M7's
1346/// Plugin contract replaces this with `Plugin::name()`.
1347pub const APP_PLUGIN_NAME: &str = "app";
1348
1349/// Default directory for migration files. `make` writes into
1350/// `migrations/<plugin>/`; `run` reads from the same place. Override
1351/// with `--migrations-dir` once the CLI grows real arg parsing (M5+).
1352pub const MIGRATIONS_DIR: &str = "migrations";
1353
1354/// The state of a single migration from the perspective of drift detection.
1355/// Returned inside [`DriftReport`] so callers can decide how to handle each
1356/// state independently.
1357#[derive(Debug, Clone, PartialEq, Eq)]
1358pub enum MigrationStatus {
1359    /// The migration is recorded in the tracking table AND the file
1360    /// exists on disk. Normal applied state.
1361    Applied,
1362    /// The migration is recorded in the tracking table BUT the
1363    /// corresponding file is missing from disk. The database is ahead
1364    /// of what version control has; recovering requires restoring the
1365    /// file or running with `--allow-drift`.
1366    AppliedButMissing,
1367    /// The migration file exists on disk AND its sequence number is
1368    /// lower than the highest applied migration for this plugin, but it
1369    /// is not recorded in the tracking table. Looks like someone dropped
1370    /// a migration file back into a directory after a teammate already
1371    /// applied later ones. Should warn, not error.
1372    OutOfOrder,
1373    /// Normal pending state: the file is on disk and its sequence number
1374    /// is higher than anything applied. Ready to apply.
1375    Pending,
1376}
1377
1378/// Per-migration entry inside a [`DriftReport`].
1379#[derive(Debug, Clone, PartialEq, Eq)]
1380pub struct MigrationEntry {
1381    pub plugin: String,
1382    pub name: String,
1383    pub status: MigrationStatus,
1384}
1385
1386/// The output of [`detect_drift`]: one entry per migration (applied or
1387/// on-disk), categorised into the four states above.
1388///
1389/// The caller inspects `has_critical_drift()` to decide whether to abort
1390/// before applying migrations. Surfaced by `show_in_with_drift` for
1391/// `showmigrations` and checked by `run_in_with_drift_check` before
1392/// executing any SQL.
1393#[derive(Debug, Clone, Default)]
1394pub struct DriftReport {
1395    pub entries: Vec<MigrationEntry>,
1396}
1397
1398impl DriftReport {
1399    /// Returns true when at least one migration is `AppliedButMissing`.
1400    /// This state means the tracking table references a file that no
1401    /// longer exists on disk — the operator needs to act before it is
1402    /// safe to continue applying new migrations.
1403    pub fn has_critical_drift(&self) -> bool {
1404        self.entries
1405            .iter()
1406            .any(|e| e.status == MigrationStatus::AppliedButMissing)
1407    }
1408
1409    /// All migrations with `AppliedButMissing` status. Convenience
1410    /// accessor for building the error message.
1411    pub fn missing_on_disk(&self) -> Vec<&MigrationEntry> {
1412        self.entries
1413            .iter()
1414            .filter(|e| e.status == MigrationStatus::AppliedButMissing)
1415            .collect()
1416    }
1417}
1418
1419/// Errors the migration engine can produce.
1420#[derive(Debug)]
1421pub enum MigrateError {
1422    /// IO error reading or writing a migration file or directory.
1423    Io(std::io::Error),
1424    /// JSON parse error on a migration file.
1425    Json(serde_json::Error),
1426    /// sqlx error executing a migration's SQL or touching the
1427    /// tracking table.
1428    Sqlx(sqlx::Error),
1429    /// `make` ran but found no differences against the latest snapshot,
1430    /// so there's nothing to write. Surfaced so the CLI can print
1431    /// "no changes detected" instead of an empty migration file.
1432    NoChanges,
1433    /// The current models diverge from the snapshot in a way M5 v1
1434    /// can't represent yet (anything other than create/drop table).
1435    /// M5.1 lifts this when column-level ops land.
1436    UnsupportedChange(String),
1437    /// A column-level change the engine can't apply automatically:
1438    /// type change, or a nullable flip on a populated SQLite table.
1439    /// Surfaces from `diff` so the build stops before producing a
1440    /// migration that would lose data or fail to apply. The user
1441    /// resolves by hand-writing the migration with the appropriate
1442    /// data-preserving steps. Carries the model / column / reason.
1443    UnsafeAlter {
1444        model: String,
1445        column: String,
1446        reason: String,
1447    },
1448    /// The tracking table records migrations that no longer have
1449    /// corresponding files on disk. Carries the list of missing names.
1450    /// The operator must either restore the files from VCS or run with
1451    /// `--allow-drift` to proceed despite the inconsistency.
1452    DriftDetected { missing: Vec<(String, String)> },
1453    /// A schema-scoped migration ([`run_for_schema`]) was requested against a
1454    /// SQLite pool. SQLite has no schemas, so schema-per-tenant is Postgres-only
1455    /// (mirrors how `Inet`/`Cidr` gate on backend). Carries the schema name.
1456    SchemaUnsupportedOnSqlite { schema: String },
1457    /// `makemigrations --empty <plugin>` named a plugin that isn't
1458    /// registered. Carries the requested name and the registered set so
1459    /// the CLI can list the valid choices.
1460    UnknownPlugin {
1461        requested: String,
1462        known: Vec<String>,
1463    },
1464    /// audit_2 H23 — the column-shape rename heuristic found an unpaired
1465    /// dropped model and an unpaired created model with **identical** column
1466    /// shapes. That's genuinely ambiguous: it's either a model rename (move the
1467    /// old table's rows to the new name) or two unrelated models that happen to
1468    /// share a shape (drop the old, create the new empty). Auto-applying either
1469    /// silently loses or mis-associates data, so `diff` refuses to guess and
1470    /// fails closed. The operator resolves it explicitly via
1471    /// `UMBRAL_MIGRATIONS_ASSUME_RENAMES` (`assume` → rename, `independent` →
1472    /// drop+create) or by hand-writing the op. Carries the two table names.
1473    AmbiguousRename {
1474        from_table: String,
1475        to_table: String,
1476    },
1477    /// audit_2 core-migrate #7 — couldn't acquire the Postgres migration
1478    /// advisory lock within the timeout: another process has been holding it
1479    /// (running a long migration, or wedged). Carries the alias/schema the lock
1480    /// was keyed on and the seconds waited. The operator retries once the other
1481    /// migrator finishes, or investigates a stuck migration.
1482    MigrationLockTimeout {
1483        discriminator: String,
1484        waited_secs: u64,
1485    },
1486    /// gaps2 #100 — a squash migration is stuck in a partially-applied state:
1487    /// SOME of the migrations it replaces are recorded as applied and others
1488    /// aren't, and the unapplied originals are no longer on disk to fall back
1489    /// to. The engine can't safely apply the squash (it would re-run schema the
1490    /// applied originals already built) nor complete the originals (their files
1491    /// are gone). Carries the plugin and squash id. The operator restores the
1492    /// missing original files from VCS (finish the transition), or resets the
1493    /// tracking rows for this plugin to a consistent point.
1494    SquashInconsistent {
1495        plugin: String,
1496        squash: String,
1497        missing: Vec<String>,
1498    },
1499    /// gaps2 #100 — `squashmigrations <plugin>` couldn't produce a squash:
1500    /// fewer than two squashable migrations, an unknown plugin, or the history
1501    /// already contains a squash (nested squashing isn't supported yet). Carries
1502    /// the plugin and a human-readable reason.
1503    CannotSquash { plugin: String, reason: String },
1504}
1505
1506impl std::fmt::Display for MigrateError {
1507    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1508        match self {
1509            MigrateError::Io(e) => write!(f, "umbral migrate: io: {e}"),
1510            MigrateError::Json(e) => write!(f, "umbral migrate: json: {e}"),
1511            MigrateError::Sqlx(e) => write!(f, "umbral migrate: sqlx: {e}"),
1512            MigrateError::NoChanges => write!(
1513                f,
1514                "umbral migrate: no changes detected; declare or change a model first"
1515            ),
1516            MigrateError::UnsupportedChange(msg) => {
1517                write!(f, "umbral migrate: unsupported change at M5 v1: {msg}")
1518            }
1519            MigrateError::UnsafeAlter {
1520                model,
1521                column,
1522                reason,
1523            } => write!(
1524                f,
1525                "umbral migrate: unsafe column change on `{model}.{column}`: {reason}; \
1526                 hand-write the migration with a data-preserving step"
1527            ),
1528            MigrateError::DriftDetected { missing } => {
1529                let names: Vec<String> = missing
1530                    .iter()
1531                    .map(|(plugin, name)| format!("{plugin}/{name}"))
1532                    .collect();
1533                write!(
1534                    f,
1535                    "umbral migrate: drift detected — the following migrations are recorded in \
1536                     the tracking table but their files are missing from disk:\n  {}\n\
1537                     Restore the files from VCS or run `umbral migrate --allow-drift` to \
1538                     proceed despite the inconsistency.",
1539                    names.join("\n  ")
1540                )
1541            }
1542            MigrateError::SchemaUnsupportedOnSqlite { schema } => write!(
1543                f,
1544                "umbral migrate: schema-per-tenant migration into `{schema}` requires \
1545                 Postgres; SQLite has no schemas. Point the app at a Postgres pool."
1546            ),
1547            MigrateError::UnknownPlugin { requested, known } => write!(
1548                f,
1549                "umbral makemigrations --empty: no registered plugin named `{requested}`. \
1550                 Known plugins: {}",
1551                known.join(", ")
1552            ),
1553            MigrateError::AmbiguousRename {
1554                from_table,
1555                to_table,
1556            } => write!(
1557                f,
1558                "umbral makemigrations: ambiguous rename — the dropped model `{from_table}` and \
1559                 the new model `{to_table}` have identical column shapes, so this is either a \
1560                 rename (move `{from_table}`'s rows to `{to_table}`) or two unrelated models. \
1561                 Refusing to guess: auto-renaming would hand one model's rows to another and skip \
1562                 the intended drop, while auto-dropping would delete `{from_table}`'s rows — both \
1563                 silent data bugs. Resolve it: set UMBRAL_MIGRATIONS_ASSUME_RENAMES=assume to \
1564                 treat every shape match as a rename, or =independent to treat them as unrelated \
1565                 (drop + create), or hand-write the intended op into the migration file."
1566            ),
1567            MigrateError::MigrationLockTimeout {
1568                discriminator,
1569                waited_secs,
1570            } => write!(
1571                f,
1572                "umbral migrate: timed out after {waited_secs}s waiting for the Postgres \
1573                 migration lock (alias/schema `{discriminator}`). Another process is holding it — \
1574                 a long-running migration on another replica, or a wedged migrator. Retry once it \
1575                 finishes; if nothing is migrating, check for a stuck backend holding \
1576                 pg_advisory_lock."
1577            ),
1578            MigrateError::SquashInconsistent {
1579                plugin,
1580                squash,
1581                missing,
1582            } => write!(
1583                f,
1584                "umbral migrate: squash `{plugin}/{squash}` is half-applied — some of the \
1585                 migrations it replaces are recorded as applied and the unapplied ones are \
1586                 missing from disk:\n  {}\nRestore those original migration files from VCS so \
1587                 the transition can finish, then re-run migrate.",
1588                missing.join("\n  ")
1589            ),
1590            MigrateError::CannotSquash { plugin, reason } => write!(
1591                f,
1592                "umbral squashmigrations: can't squash `{plugin}`: {reason}"
1593            ),
1594        }
1595    }
1596}
1597
1598impl std::error::Error for MigrateError {}
1599
1600impl From<std::io::Error> for MigrateError {
1601    fn from(e: std::io::Error) -> Self {
1602        Self::Io(e)
1603    }
1604}
1605
1606impl From<serde_json::Error> for MigrateError {
1607    fn from(e: serde_json::Error) -> Self {
1608        Self::Json(e)
1609    }
1610}
1611
1612impl From<sqlx::Error> for MigrateError {
1613    fn from(e: sqlx::Error) -> Self {
1614        Self::Sqlx(e)
1615    }
1616}
1617
1618// =========================================================================
1619// Top-level entry points.
1620// =========================================================================
1621
1622/// Generate one migration file per registered plugin that has changes,
1623/// diffing each plugin's current model set against the latest snapshot
1624/// in `migrations/<plugin>/`. Each new file lands inside its own
1625/// plugin directory with the next sequence number and a `_<short_name>`
1626/// suffix derived from the dominant operation.
1627///
1628/// Returns the paths of every file written, one per plugin that had a
1629/// non-empty diff. Returns `MigrateError::NoChanges` if no plugin
1630/// produced any changes at all.
1631pub async fn make() -> Result<Vec<PathBuf>, MigrateError> {
1632    make_in(Path::new(MIGRATIONS_DIR)).await
1633}
1634
1635/// Same as [`make`] but takes an explicit base directory. Used by
1636/// tests to avoid touching the cwd.
1637///
1638/// Iterates [`plugin_order`], which is the topological order
1639/// published by `App::build()`'s phase 1.5 sort. Cross-plugin FKs
1640/// land in dependency order this way (a plugin's `CreateTable` for
1641/// the FK target runs before the dependent plugin's `CreateTable`).
1642/// Falls back to [`registered_plugins`] when no order has been
1643/// published (e.g. low-level tests that init the registry directly).
1644pub async fn make_in(dir: &Path) -> Result<Vec<PathBuf>, MigrateError> {
1645    let mut written: Vec<PathBuf> = Vec::new();
1646
1647    for plugin in plugin_order() {
1648        let plugin_dir = dir.join(&plugin);
1649
1650        // The previous snapshot is the `snapshot_after` of the highest-
1651        // numbered migration file (filenames are zero-padded so lexical
1652        // sort matches numeric order). An empty or missing directory
1653        // means "no prior state", the first-run case for this plugin.
1654        let existing = list_migration_files(&plugin_dir)?;
1655        let previous = match existing.last() {
1656            Some(path) => read_migration_file(path)?.snapshot_after,
1657            None => Snapshot::default(),
1658        };
1659
1660        let current = Snapshot::current_for(&plugin);
1661        let operations = diff(&previous, &current)?;
1662        if operations.is_empty() {
1663            continue;
1664        }
1665
1666        let seq = (existing.len() + 1) as u32;
1667        let suffix = suffix_for(&operations);
1668        let id = format!("{seq:04}_{suffix}");
1669        let filename = format!("{id}.json");
1670
1671        let file = MigrationFile {
1672            id: id.clone(),
1673            plugin: plugin.clone(),
1674            depends_on: Vec::new(),
1675            operations,
1676            snapshot_after: current,
1677            replaces: Vec::new(),
1678        };
1679
1680        std::fs::create_dir_all(&plugin_dir)?;
1681        let path = plugin_dir.join(filename);
1682        let json = serde_json::to_string_pretty(&file)?;
1683        std::fs::write(&path, json)?;
1684        written.push(path);
1685    }
1686
1687    if written.is_empty() {
1688        return Err(MigrateError::NoChanges);
1689    }
1690    Ok(written)
1691}
1692
1693/// Write an **empty** migration for one plugin: the current snapshot
1694/// with an empty `operations` list, the authoring stub for a
1695/// hand-written data migration (`Operation::RunSql`). The developer
1696/// opens the file and adds a `RunSql { sql, reverse_sql }` op.
1697///
1698/// The empty op-list means `snapshot_after == snapshot_before`, so the
1699/// next `make` diffs against the same state and produces nothing — a
1700/// data migration never disturbs the schema-snapshot chain. Mirror of
1701/// [`make`] for the `--empty <plugin>` CLI path.
1702pub async fn make_empty(plugin: &str) -> Result<PathBuf, MigrateError> {
1703    make_empty_in(Path::new(MIGRATIONS_DIR), plugin).await
1704}
1705
1706/// Same as [`make_empty`] but takes an explicit base directory. The
1707/// seam tests drive.
1708pub async fn make_empty_in(dir: &Path, plugin: &str) -> Result<PathBuf, MigrateError> {
1709    // The plugin must be registered, else the snapshot/sequence would be
1710    // meaningless. Fail loudly with the known set.
1711    let known = plugin_order();
1712    if !known.iter().any(|p| p == plugin) {
1713        return Err(MigrateError::UnknownPlugin {
1714            requested: plugin.to_string(),
1715            known,
1716        });
1717    }
1718
1719    let plugin_dir = dir.join(plugin);
1720
1721    // Carry the latest snapshot forward verbatim: an empty migration has
1722    // NO schema effect, so `snapshot_after` equals the previous one. The
1723    // current model snapshot is the same as the prior file's
1724    // `snapshot_after` (no model changed); use the current registry state
1725    // so the file is self-consistent even on a plugin's very first
1726    // migration.
1727    let existing = list_migration_files(&plugin_dir)?;
1728    let snapshot = match existing.last() {
1729        Some(path) => read_migration_file(path)?.snapshot_after,
1730        None => Snapshot::current_for(plugin),
1731    };
1732
1733    let seq = (existing.len() + 1) as u32;
1734    let id = format!("{seq:04}_empty");
1735    let filename = format!("{id}.json");
1736
1737    let file = MigrationFile {
1738        id: id.clone(),
1739        plugin: plugin.to_string(),
1740        depends_on: Vec::new(),
1741        operations: Vec::new(),
1742        snapshot_after: snapshot,
1743        replaces: Vec::new(),
1744    };
1745
1746    std::fs::create_dir_all(&plugin_dir)?;
1747    let path = plugin_dir.join(filename);
1748    let json = serde_json::to_string_pretty(&file)?;
1749    std::fs::write(&path, json)?;
1750    Ok(path)
1751}
1752
1753/// The numeric sequence prefix of a migration id (`0003_add_x` → `0003`),
1754/// used to name a squash `0001_squashed_0003`.
1755fn seq_prefix(id: &str) -> &str {
1756    id.split('_').next().unwrap_or(id)
1757}
1758
1759/// Outcome of [`squash_in`]: where the squash landed and what it collapsed.
1760pub struct SquashOutcome {
1761    /// Path of the written squash file.
1762    pub path: PathBuf,
1763    /// Id of the new squash migration (e.g. `0001_squashed_0005`).
1764    pub id: String,
1765    /// Ids of the original migrations it replaces — kept on disk, non-destructive.
1766    pub replaced: Vec<String>,
1767}
1768
1769/// gaps2 #100 — collapse a plugin's entire linear migration history into ONE
1770/// optimized squash file, **non-destructively**: the originals stay on disk so
1771/// older deploys still migrate, and the runner ([`squash_plan`]) treats the
1772/// squash and its originals as mutually exclusive.
1773///
1774/// The squash's operations are the diff from an empty schema to the last
1775/// migration's `snapshot_after` — i.e. one `CreateTable` per model with every
1776/// intermediate `AlterColumn` already folded into the final column set. That's
1777/// the minimal replay of the whole history, which is the entire point (a 20-file
1778/// history with repeated alters collapses to N clean creates).
1779///
1780/// Refuses (with [`MigrateError::CannotSquash`]) when it can't produce a safe
1781/// squash: fewer than two migrations, a history that already contains a squash
1782/// (nested squashing is out of scope), or a history containing a `RunSql` data
1783/// migration — a snapshot diff can't see hand-written data steps, so squashing
1784/// across one would silently drop it. The operator squashes the schema-only
1785/// prefix, or leaves that history intact.
1786pub fn squash_in(dir: &Path, plugin: &str) -> Result<SquashOutcome, MigrateError> {
1787    let plugin_dir = dir.join(plugin);
1788    let paths = list_migration_files(&plugin_dir)?;
1789    let files: Vec<MigrationFile> = paths
1790        .iter()
1791        .map(|p| read_migration_file(p))
1792        .collect::<Result<_, _>>()?;
1793
1794    if files.len() < 2 {
1795        return Err(MigrateError::CannotSquash {
1796            plugin: plugin.to_string(),
1797            reason: format!(
1798                "need at least 2 migrations to squash, found {}",
1799                files.len()
1800            ),
1801        });
1802    }
1803    if files.iter().any(|f| !f.replaces.is_empty()) {
1804        return Err(MigrateError::CannotSquash {
1805            plugin: plugin.to_string(),
1806            reason: "this history already contains a squash; nested squashing isn't supported \
1807                     yet. Delete the now-redundant original files once every deploy has migrated, \
1808                     then squash again"
1809                .to_string(),
1810        });
1811    }
1812    if files.iter().any(|f| {
1813        f.operations
1814            .iter()
1815            .any(|op| matches!(op, Operation::RunSql { .. }))
1816    }) {
1817        return Err(MigrateError::CannotSquash {
1818            plugin: plugin.to_string(),
1819            reason: "this history contains a RunSql data migration, which a snapshot diff can't \
1820                     reconstruct — squashing across it would silently drop the data step. Squash \
1821                     the schema-only migrations before/after it, or leave this history intact"
1822                .to_string(),
1823        });
1824    }
1825
1826    let first = &files[0];
1827    let last = files.last().expect("len >= 2 checked above");
1828
1829    // Optimal from-scratch replay: empty schema → final snapshot. Every
1830    // intermediate alter is already baked into `snapshot_after`.
1831    let empty = Snapshot { models: Vec::new() };
1832    let operations = diff(&empty, &last.snapshot_after)?;
1833
1834    let id = format!(
1835        "{}_squashed_{}",
1836        seq_prefix(&first.id),
1837        seq_prefix(&last.id)
1838    );
1839    let replaced: Vec<String> = files.iter().map(|f| f.id.clone()).collect();
1840    let squash = MigrationFile {
1841        id: id.clone(),
1842        plugin: plugin.to_string(),
1843        // Preserve the first migration's cross-plugin predecessors: the squash
1844        // stands in for the whole run, so it inherits the run's external deps.
1845        depends_on: first.depends_on.clone(),
1846        operations,
1847        snapshot_after: last.snapshot_after.clone(),
1848        replaces: replaced
1849            .iter()
1850            .map(|mid| MigrationRef {
1851                plugin: plugin.to_string(),
1852                migration: mid.clone(),
1853            })
1854            .collect(),
1855    };
1856
1857    let path = plugin_dir.join(format!("{id}.json"));
1858    let json = serde_json::to_string_pretty(&squash)?;
1859    std::fs::write(&path, json)?;
1860    Ok(SquashOutcome { path, id, replaced })
1861}
1862
1863/// Apply every pending migration across every registered plugin's
1864/// `migrations/<plugin>/` directory to the ambient pool. Reads the
1865/// `umbral_migrations` tracking table to determine "pending"; each
1866/// migration runs in its own transaction along with its tracking-table
1867/// insert.
1868///
1869/// Returns the total number of migrations applied (zero if every
1870/// plugin's migrations were already in the tracking table).
1871///
1872/// This variant performs a drift check before executing any SQL. If
1873/// any migration is `AppliedButMissing` (in the DB but not on disk),
1874/// the call returns [`MigrateError::DriftDetected`] listing the
1875/// missing names. Pass `allow_drift = true` (via [`run_checked_in`])
1876/// to suppress the error and proceed anyway (with a warning printed to
1877/// stderr).
1878pub async fn run() -> Result<u64, MigrateError> {
1879    run_checked(false).await
1880}
1881
1882/// Same as [`run`] but controls drift handling.
1883/// `allow_drift = true` corresponds to the `--allow-drift` CLI flag:
1884/// the command logs a warning and proceeds even if some applied
1885/// migrations are missing on disk.
1886pub async fn run_checked(allow_drift: bool) -> Result<u64, MigrateError> {
1887    run_checked_in(Path::new(MIGRATIONS_DIR), allow_drift).await
1888}
1889
1890/// Same as [`run_checked`] but takes an explicit base directory.
1891pub async fn run_checked_in(dir: &Path, allow_drift: bool) -> Result<u64, MigrateError> {
1892    let mut total: u64 = 0;
1893    // Walk every registered DB. Drift-detection on the default pool
1894    // is the dominant flow; secondary pools currently use the same
1895    // tracking-table-vs-disk comparison but only against the
1896    // migration files whose ops actually targeted that DB. A future
1897    // pass can teach `detect_all_drift` to be alias-aware so drift
1898    // warnings name the offending pool — today it warns once per
1899    // checked DB if the issue is present in any.
1900    for alias in crate::db::registered_aliases() {
1901        match crate::db::pool_for_dispatched(&alias) {
1902            crate::db::DbPool::Sqlite(p) => {
1903                total += run_in_sqlite_checked(dir, p, allow_drift, &alias).await?
1904            }
1905            crate::db::DbPool::Postgres(p) => {
1906                total += run_in_postgres_checked(dir, p, allow_drift, &alias).await?
1907            }
1908        }
1909    }
1910    Ok(total)
1911}
1912
1913/// Same as [`run`] but takes an explicit base directory. Used by
1914/// tests to avoid touching the cwd.
1915///
1916/// Iterates `registered_plugins()` in sorted-by-name order. M7 v1
1917/// accepts this as a limitation: cross-plugin FK ordering wants
1918/// topological order across plugins (the FK target's `CreateTable`
1919/// applies before the dependent plugin's `CreateTable`), but the
1920/// engine doesn't see `Plugin::dependencies()` from inside this
1921/// standalone function. M8 lifts the limitation via a registry that
1922/// remembers the toposorted order computed at `App::build()` time.
1923///
1924/// This legacy entry point does NOT perform drift checking so the
1925/// existing tests (which bypass drift by design) keep passing. New
1926/// callers should prefer [`run_checked_in`].
1927pub async fn run_in(dir: &Path) -> Result<u64, MigrateError> {
1928    let mut total: u64 = 0;
1929    // Walk every registered DB so each pool gets its own
1930    // `umbral_migrations` table and runs only the operations targeting
1931    // tables routed to it. Order is alphabetical for determinism;
1932    // the "default" pool is always present.
1933    for alias in crate::db::registered_aliases() {
1934        match crate::db::pool_for_dispatched(&alias) {
1935            crate::db::DbPool::Sqlite(p) => {
1936                total += run_in_sqlite_for_alias(dir, &alias, p, None).await?
1937            }
1938            crate::db::DbPool::Postgres(p) => {
1939                total += run_in_postgres_for_alias(dir, &alias, p, None).await?
1940            }
1941        }
1942    }
1943    Ok(total)
1944}
1945
1946/// Apply only the **SHARED** apps' pending migrations to the default pool —
1947/// the `public`/shared half of schema-per-tenant multitenancy. This is the
1948/// mirror of [`run_for_schema_in`] (which migrates the *tenant* apps into a
1949/// tenant schema): here only plugins IN `shared_apps` migrate into `public`,
1950/// so a tenant app's tables — and crucially its M2M junctions — are NEVER
1951/// created in `public`. They live only in each tenant schema, where a junction's
1952/// FK to a SHARED child resolves via the `<schema>, public` search-path.
1953///
1954/// Use this instead of the unfiltered [`run`]/[`run_in`] when running a
1955/// schema-per-tenant app: `run_shared` (shared → public) then `migrate_schemas`
1956/// (tenant apps → each schema). On a non-multitenant app the two are equivalent
1957/// only if every app is shared; otherwise prefer plain [`run`].
1958pub async fn run_shared(
1959    shared_apps: &std::collections::HashSet<String>,
1960) -> Result<u64, MigrateError> {
1961    run_shared_in(Path::new(MIGRATIONS_DIR), shared_apps).await
1962}
1963
1964/// [`run_shared`] against an explicit migrations directory (tests / tooling).
1965pub async fn run_shared_in(
1966    dir: &Path,
1967    shared_apps: &std::collections::HashSet<String>,
1968) -> Result<u64, MigrateError> {
1969    let mut total: u64 = 0;
1970    for alias in crate::db::registered_aliases() {
1971        match crate::db::pool_for_dispatched(&alias) {
1972            crate::db::DbPool::Sqlite(p) => {
1973                total += run_in_sqlite_for_alias(dir, &alias, p, Some(shared_apps)).await?
1974            }
1975            crate::db::DbPool::Postgres(p) => {
1976                total += run_in_postgres_for_alias(dir, &alias, p, Some(shared_apps)).await?
1977            }
1978        }
1979    }
1980    Ok(total)
1981}
1982
1983/// Predicate: does `op` target a table that lives on `alias`?
1984///
1985/// Routing rule: look up the table → alias mapping via
1986/// [`table_alias`]. Tables not owned by any registered model fall
1987/// through to `"default"` so the migration engine's own
1988/// `umbral_migrations` book-keeping stays in the main DB.
1989///
1990/// A second gate consults the installed [`DatabaseRouter`]: if the
1991/// router's [`allow_migrate`](crate::db::DatabaseRouter::allow_migrate)
1992/// returns `false` for this (alias, model) pair the operation is
1993/// excluded from the alias's run. Junction / unowned tables (no
1994/// registered `ModelMeta`) are always allowed — the router has no
1995/// model to inspect.
1996fn op_targets_alias(op: &Operation, alias: &str) -> bool {
1997    if table_alias(op.table_name()) != alias {
1998        return false;
1999    }
2000    // Let the router veto migrating this table on this alias.
2001    match model_meta_for_table(op.table_name()) {
2002        Some(meta) => crate::db::router::router().allow_migrate(alias, &meta),
2003        None => true, // junction / unowned table — migrate on its alias
2004    }
2005}
2006
2007/// SQLite per-alias variant. Same shape as the legacy `run_in_sqlite`
2008/// but: filters ops to those routed to `alias`; skips files whose op
2009/// list contains nothing for this DB (so we don't stuff orphan
2010/// tracking rows into pools that didn't run any SQL).
2011async fn run_in_sqlite_for_alias(
2012    dir: &Path,
2013    alias: &str,
2014    pool: &sqlx::SqlitePool,
2015    shared_only: Option<&std::collections::HashSet<String>>,
2016) -> Result<u64, MigrateError> {
2017    ensure_tracking_table_sqlite(pool).await?;
2018    let applied = applied_names_sqlite(pool).await?;
2019
2020    let mut applied_count: u64 = 0;
2021    for plugin in plugin_order() {
2022        if let Some(shared) = shared_only {
2023            if !shared.contains(&plugin) {
2024                continue;
2025            }
2026        }
2027        let plugin_dir = dir.join(&plugin);
2028        let paths = list_migration_files(&plugin_dir)?;
2029
2030        // Read the plugin's full file set, then plan squash decisions in one
2031        // pass (gaps2 #100). `squash_plan` decides Apply / Skip / RecordOnly per
2032        // file, honoring `replaces` so a squash and its originals never both
2033        // apply. Pure and backend-agnostic — the Postgres runner plans the same.
2034        let files: Vec<MigrationFile> = paths
2035            .iter()
2036            .map(|p| read_migration_file(p))
2037            .collect::<Result<_, _>>()?;
2038        let plan = squash_plan(&files, &applied)?;
2039
2040        for (file, decision) in files.iter().zip(plan) {
2041            if decision == ApplyDecision::Skip {
2042                continue;
2043            }
2044
2045            let ops_for_this_db: Vec<&Operation> = file
2046                .operations
2047                .iter()
2048                .filter(|op| op_targets_alias(op, alias))
2049                .collect();
2050            if ops_for_this_db.is_empty() {
2051                // File's content all targets some other DB. Don't
2052                // record it here — re-runs will re-evaluate cleanly
2053                // once the right DB picks it up. The tracking rows
2054                // per-DB stay accurate to "what actually ran here."
2055                continue;
2056            }
2057
2058            let snapshot_hash = file.snapshot_after.hash();
2059            let applied_at = chrono::Utc::now().to_rfc3339();
2060            // RecordOnly: the squash's replaced set is already applied, so its
2061            // schema exists — insert the tracking row without running any ops
2062            // (an empty op slice inserts the row and commits).
2063            let ops_to_run: &[&Operation] = if decision == ApplyDecision::RecordOnly {
2064                &[]
2065            } else {
2066                &ops_for_this_db
2067            };
2068            apply_sqlite_migration_tx(
2069                pool,
2070                ops_to_run,
2071                &file.plugin,
2072                &file.id,
2073                &applied_at,
2074                &snapshot_hash,
2075            )
2076            .await?;
2077            applied_count += 1;
2078        }
2079    }
2080    Ok(applied_count)
2081}
2082
2083/// Apply one migration file's SQLite `ops` in a single transaction, then record
2084/// it in the tracking table.
2085///
2086/// When any op is an `AlterColumn` (the table-recreation dance), the
2087/// transaction is bracketed with `PRAGMA foreign_keys=OFF` … `PRAGMA
2088/// foreign_key_check` … `PRAGMA foreign_keys=ON` on a **pinned** connection
2089/// (SQLite's official recipe), so step 3's `DROP TABLE` on a table with inbound
2090/// FKs doesn't fail with `FOREIGN KEY constraint failed` (error 787, gaps3
2091/// #13). The pragma MUST be toggled outside the tx (it's a no-op inside one),
2092/// and enforcement is restored even on failure so the pooled connection never
2093/// returns with FK checks disabled. `foreign_key_check` before commit keeps the
2094/// integrity guarantee: a migration that genuinely orphans a row is aborted.
2095async fn apply_sqlite_migration_tx(
2096    pool: &sqlx::SqlitePool,
2097    ops: &[&Operation],
2098    plugin: &str,
2099    name: &str,
2100    applied_at: &str,
2101    snapshot_hash: &str,
2102) -> Result<(), MigrateError> {
2103    use sqlx::Acquire as _;
2104
2105    let needs_fk_off = ops
2106        .iter()
2107        .any(|op| matches!(op, Operation::AlterColumn { .. }));
2108
2109    let mut conn = pool.acquire().await?;
2110    if needs_fk_off {
2111        sqlx::query("PRAGMA foreign_keys=OFF")
2112            .execute(&mut *conn)
2113            .await?;
2114    }
2115
2116    let result: Result<(), MigrateError> = async {
2117        let mut tx = conn.begin().await?;
2118        for op in ops {
2119            for sql in render_operation_for(op, "sqlite") {
2120                sqlx::query(&sql).execute(&mut *tx).await?;
2121            }
2122        }
2123        if needs_fk_off {
2124            // Enforcement was off during the dance; verify the recreation left
2125            // no dangling references before we commit.
2126            let violations = sqlx::query("PRAGMA foreign_key_check")
2127                .fetch_all(&mut *tx)
2128                .await?;
2129            if !violations.is_empty() {
2130                return Err(MigrateError::Sqlx(sqlx::Error::Protocol(format!(
2131                    "migration `{plugin}/{name}` would leave {} dangling foreign-key \
2132                     reference(s); aborted",
2133                    violations.len()
2134                ))));
2135            }
2136        }
2137        sqlx::query(
2138            "INSERT INTO umbral_migrations (plugin, name, applied_at, snapshot_hash) \
2139             VALUES (?, ?, ?, ?)",
2140        )
2141        .bind(plugin)
2142        .bind(name)
2143        .bind(applied_at)
2144        .bind(snapshot_hash)
2145        .execute(&mut *tx)
2146        .await?;
2147        tx.commit().await?;
2148        Ok(())
2149    }
2150    .await;
2151
2152    if needs_fk_off {
2153        // Restore enforcement before the connection returns to the pool, even
2154        // on failure — but never mask the primary error with a pragma error.
2155        let _ = sqlx::query("PRAGMA foreign_keys=ON")
2156            .execute(&mut *conn)
2157            .await;
2158    }
2159    result
2160}
2161
2162/// A stable 64-bit key for the Postgres migration advisory lock, derived from a
2163/// fixed namespace + a `discriminator` (the pool alias or tenant schema). FNV-1a
2164/// with fixed constants — deterministic and process-independent, so every
2165/// migrator computes the SAME key for the same target and they mutually exclude.
2166/// Different aliases/schemas get different keys, so unrelated logical databases
2167/// migrate concurrently (audit_2 core-migrate #7).
2168fn pg_migration_lock_key(discriminator: &str) -> i64 {
2169    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
2170    for b in b"umbral_migrations\0"
2171        .iter()
2172        .copied()
2173        .chain(discriminator.bytes())
2174    {
2175        hash ^= b as u64;
2176        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
2177    }
2178    hash as i64
2179}
2180
2181/// How long a migrator waits for the advisory lock before giving up. Generous
2182/// (another replica's migration set can take a while), bounded so a deploy can't
2183/// hang forever on a wedged migrator. Override with
2184/// `UMBRAL_MIGRATION_LOCK_TIMEOUT_SECS`.
2185fn pg_migration_lock_timeout() -> std::time::Duration {
2186    let secs = std::env::var("UMBRAL_MIGRATION_LOCK_TIMEOUT_SECS")
2187        .ok()
2188        .and_then(|v| v.trim().parse::<u64>().ok())
2189        .filter(|&n| n > 0)
2190        .unwrap_or(300);
2191    std::time::Duration::from_secs(secs)
2192}
2193
2194/// Acquire the session-level Postgres advisory lock keyed on `discriminator`,
2195/// holding it on `conn` for the caller to release. Non-blocking polls
2196/// (`pg_try_advisory_lock`) with a short sleep between attempts, bounded by
2197/// [`pg_migration_lock_timeout`] — so a stuck migrator surfaces as a clear
2198/// [`MigrateError::MigrationLockTimeout`] instead of an indefinite hang, and a
2199/// crashed migrator's lock auto-releases (session locks die with the backend).
2200async fn acquire_pg_migration_lock(
2201    conn: &mut sqlx::PgConnection,
2202    key: i64,
2203    discriminator: &str,
2204) -> Result<(), MigrateError> {
2205    let timeout = pg_migration_lock_timeout();
2206    let start = std::time::Instant::now();
2207    let mut warned = false;
2208    loop {
2209        let got: bool = sqlx::query_scalar("SELECT pg_try_advisory_lock($1)")
2210            .bind(key)
2211            .fetch_one(&mut *conn)
2212            .await?;
2213        if got {
2214            return Ok(());
2215        }
2216        let waited = start.elapsed();
2217        if waited >= timeout {
2218            return Err(MigrateError::MigrationLockTimeout {
2219                discriminator: discriminator.to_string(),
2220                waited_secs: waited.as_secs(),
2221            });
2222        }
2223        if !warned {
2224            tracing::info!(
2225                discriminator,
2226                "umbral migrate: another process holds the migration lock; waiting for it to \
2227                 finish before applying migrations…"
2228            );
2229            warned = true;
2230        }
2231        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
2232    }
2233}
2234
2235/// Release the advisory lock acquired by [`acquire_pg_migration_lock`]. Best
2236/// effort — dropping `conn` also releases a session lock, so a failure here is
2237/// logged, not propagated (it must never mask the migration's own result).
2238async fn release_pg_migration_lock(conn: &mut sqlx::PgConnection, key: i64) {
2239    if let Err(e) = sqlx::query("SELECT pg_advisory_unlock($1)")
2240        .bind(key)
2241        .execute(&mut *conn)
2242        .await
2243    {
2244        tracing::warn!("umbral migrate: failed to release the migration advisory lock: {e}");
2245    }
2246}
2247
2248/// Postgres per-alias variant. Mirror of `run_in_sqlite_for_alias`.
2249///
2250/// Wraps the apply loop in a session advisory lock (audit_2 core-migrate #7) so
2251/// two replicas deploying at once can't both read the applied set and race the
2252/// same DDL (the loser errors "relation already exists" mid-deploy). The lock is
2253/// held on a dedicated connection for the whole run; the per-migration
2254/// transactions use their own pooled connections and are unaffected.
2255async fn run_in_postgres_for_alias(
2256    dir: &Path,
2257    alias: &str,
2258    pool: &sqlx::PgPool,
2259    shared_only: Option<&std::collections::HashSet<String>>,
2260) -> Result<u64, MigrateError> {
2261    let key = pg_migration_lock_key(alias);
2262    let mut lock_conn = pool.acquire().await?;
2263    acquire_pg_migration_lock(&mut lock_conn, key, alias).await?;
2264    let result = run_in_postgres_for_alias_locked(dir, alias, pool, shared_only).await;
2265    release_pg_migration_lock(&mut lock_conn, key).await;
2266    result
2267}
2268
2269/// Run `f` under the Postgres migration advisory lock keyed on `discriminator`.
2270/// Shared by the checked-run and schema-per-tenant apply paths so they get the
2271/// same cross-process serialization as [`run_in_postgres_for_alias`].
2272async fn with_pg_migration_lock<F, Fut>(
2273    pool: &sqlx::PgPool,
2274    discriminator: &str,
2275    f: F,
2276) -> Result<u64, MigrateError>
2277where
2278    F: FnOnce() -> Fut,
2279    Fut: std::future::Future<Output = Result<u64, MigrateError>>,
2280{
2281    let key = pg_migration_lock_key(discriminator);
2282    let mut lock_conn = pool.acquire().await?;
2283    acquire_pg_migration_lock(&mut lock_conn, key, discriminator).await?;
2284    let result = f().await;
2285    release_pg_migration_lock(&mut lock_conn, key).await;
2286    result
2287}
2288
2289/// The unlocked body of [`run_in_postgres_for_alias`] — runs while the caller
2290/// holds the migration advisory lock.
2291async fn run_in_postgres_for_alias_locked(
2292    dir: &Path,
2293    alias: &str,
2294    pool: &sqlx::PgPool,
2295    shared_only: Option<&std::collections::HashSet<String>>,
2296) -> Result<u64, MigrateError> {
2297    ensure_tracking_table_postgres(pool).await?;
2298    let applied = applied_names_postgres(pool).await?;
2299
2300    let mut applied_count: u64 = 0;
2301    for plugin in plugin_order() {
2302        // Shared-filtered public migrate (multitenancy): when a shared-app set
2303        // is given, migrate ONLY those plugins into this pool, so a tenant
2304        // app's tables (and its M2M junctions) are NOT created in `public` —
2305        // they belong only in each tenant schema. `None` = migrate everything
2306        // (the default single-DB behaviour, byte-identical to before).
2307        if let Some(shared) = shared_only {
2308            if !shared.contains(&plugin) {
2309                continue;
2310            }
2311        }
2312        let plugin_dir = dir.join(&plugin);
2313        let paths = list_migration_files(&plugin_dir)?;
2314
2315        // Plan squash decisions for the plugin's whole file set (gaps2 #100),
2316        // identical to the SQLite runner — `squash_plan` is backend-agnostic.
2317        let files: Vec<MigrationFile> = paths
2318            .iter()
2319            .map(|p| read_migration_file(p))
2320            .collect::<Result<_, _>>()?;
2321        let plan = squash_plan(&files, &applied)?;
2322
2323        for (file, decision) in files.iter().zip(plan) {
2324            if decision == ApplyDecision::Skip {
2325                continue;
2326            }
2327
2328            let ops_for_this_db: Vec<&Operation> = file
2329                .operations
2330                .iter()
2331                .filter(|op| op_targets_alias(op, alias))
2332                .collect();
2333            if ops_for_this_db.is_empty() {
2334                continue;
2335            }
2336
2337            let mut tx = pool.begin().await?;
2338            // RecordOnly (squash whose replaced set is already applied): its
2339            // schema exists, so run no DDL — just insert the tracking row.
2340            if decision != ApplyDecision::RecordOnly {
2341                for op in &ops_for_this_db {
2342                    for sql in render_operation(op) {
2343                        sqlx::query(&sql).execute(&mut *tx).await?;
2344                    }
2345                }
2346            }
2347            let snapshot_hash = file.snapshot_after.hash();
2348            let applied_at = chrono::Utc::now().to_rfc3339();
2349            sqlx::query(
2350                "INSERT INTO umbral_migrations (plugin, name, applied_at, snapshot_hash) \
2351                 VALUES ($1, $2, $3, $4)",
2352            )
2353            .bind(&file.plugin)
2354            .bind(&file.id)
2355            .bind(&applied_at)
2356            .bind(&snapshot_hash)
2357            .execute(&mut *tx)
2358            .await?;
2359            tx.commit().await?;
2360            applied_count += 1;
2361        }
2362    }
2363    Ok(applied_count)
2364}
2365
2366/// Migrate the **tenant** apps into a named Postgres schema (schema-per-tenant
2367/// style). The migration engine owns all schema DDL; this is the
2368/// sanctioned `CREATE SCHEMA` / `SET search_path` exception (a plugin calls this
2369/// rather than writing raw schema SQL itself).
2370///
2371/// Steps, all inside one transaction per migration file (mirroring
2372/// [`run_in_postgres_for_alias`]):
2373/// 1. `CREATE SCHEMA IF NOT EXISTS "<schema>"` (the `Schema` was already
2374///    validated to a safe PG identifier, but is still emitted quoted).
2375/// 2. `SET LOCAL search_path TO "<schema>"` so every unqualified
2376///    `CREATE TABLE` **and** the `umbral_migrations` ledger land *inside*
2377///    `<schema>` — per-schema migration tracking falls out for free.
2378/// 3. Apply pending migrations, **filtered to the tenant apps** — every plugin
2379///    NOT in `shared_apps` (those tables live in `public` and are migrated by
2380///    the normal [`run`]). A file with no tenant-app ops for this schema is
2381///    skipped without a tracking row.
2382///
2383/// Idempotent: re-running applies only the migrations the schema's own
2384/// `umbral_migrations` ledger hasn't recorded. Postgres-only — schemas don't
2385/// exist on SQLite, so a SQLite pool is a clear error
2386/// ([`MigrateError::SchemaUnsupportedOnSqlite`]).
2387pub async fn run_for_schema(
2388    schema: &crate::db::Schema,
2389    shared_apps: &std::collections::HashSet<String>,
2390) -> Result<u64, MigrateError> {
2391    run_for_schema_in(Path::new(MIGRATIONS_DIR), schema, shared_apps).await
2392}
2393
2394/// Same as [`run_for_schema`] but takes an explicit migrations base directory.
2395/// The entry tests drive.
2396pub async fn run_for_schema_in(
2397    dir: &Path,
2398    schema: &crate::db::Schema,
2399    shared_apps: &std::collections::HashSet<String>,
2400) -> Result<u64, MigrateError> {
2401    match crate::db::pool_dispatched() {
2402        crate::db::DbPool::Postgres(p) => {
2403            // audit_2 core-migrate #7: serialize concurrent migrators of THIS
2404            // tenant schema (keyed by schema name, so different tenants still
2405            // migrate concurrently). The shared/public run uses a different key
2406            // (its alias), so a tenant migrate and the public migrate don't
2407            // block each other.
2408            with_pg_migration_lock(p, schema.as_str(), || {
2409                run_tenant_apps_in_postgres_schema(dir, schema, shared_apps, p)
2410            })
2411            .await
2412        }
2413        crate::db::DbPool::Sqlite(_) => Err(MigrateError::SchemaUnsupportedOnSqlite {
2414            schema: schema.as_str().to_string(),
2415        }),
2416    }
2417}
2418
2419/// Postgres schema-scoped variant of [`run_in_postgres_for_alias`]. Creates the
2420/// schema, pins `search_path` to it for the transaction, and applies only the
2421/// tenant apps' migrations (plugins not in `shared_apps`). The `umbral_migrations`
2422/// ledger is read/written *inside* the schema (search_path is set first), so
2423/// tracking is per-schema with no extra book-keeping.
2424async fn run_tenant_apps_in_postgres_schema(
2425    dir: &Path,
2426    schema: &crate::db::Schema,
2427    shared_apps: &std::collections::HashSet<String>,
2428    pool: &sqlx::PgPool,
2429) -> Result<u64, MigrateError> {
2430    let quoted = format!("\"{}\"", schema.as_str());
2431
2432    // Create the schema once, outside the per-file loop. IF NOT EXISTS makes
2433    // the whole call idempotent.
2434    sqlx::query(&format!("CREATE SCHEMA IF NOT EXISTS {quoted}"))
2435        .execute(pool)
2436        .await?;
2437
2438    // Ensure + read the ledger INSIDE the schema. Each block runs in its own
2439    // transaction with `SET LOCAL search_path` so the tracking table is created
2440    // in (and read from) `<schema>`, not `public` — AND the search_path is
2441    // transaction-scoped, so the pooled connection is NOT left pinned to this
2442    // schema when it returns to the pool. A plain session-level `SET` here
2443    // pollutes the pool: the next unqualified ORM query that reuses the
2444    // connection would resolve against `<schema>` instead of `public` (e.g. an
2445    // insert into the public `tenant` registry failing with "relation does not
2446    // exist") — a real cross-tenant bug, caught only against live Postgres.
2447    {
2448        let mut tx = pool.begin().await?;
2449        sqlx::query(&format!("SET LOCAL search_path TO {quoted}"))
2450            .execute(&mut *tx)
2451            .await?;
2452        ensure_tracking_table_pg_conn(&mut tx).await?;
2453        tx.commit().await?;
2454    }
2455    let applied = {
2456        let mut tx = pool.begin().await?;
2457        sqlx::query(&format!("SET LOCAL search_path TO {quoted}"))
2458            .execute(&mut *tx)
2459            .await?;
2460        let rows: Vec<(String, String)> =
2461            sqlx::query_as("SELECT plugin, name FROM umbral_migrations")
2462                .fetch_all(&mut *tx)
2463                .await?;
2464        tx.commit().await?;
2465        rows.into_iter().collect::<std::collections::HashSet<_>>()
2466    };
2467
2468    let mut applied_count: u64 = 0;
2469    for plugin in plugin_order() {
2470        // Tenant apps only — shared apps live in `public`.
2471        if shared_apps.contains(&plugin) {
2472            continue;
2473        }
2474        let plugin_dir = dir.join(&plugin);
2475        let paths = list_migration_files(&plugin_dir)?;
2476
2477        // Squash-aware planning (gaps2 #100), same as every other apply loop.
2478        let files: Vec<MigrationFile> = paths
2479            .iter()
2480            .map(|p| read_migration_file(p))
2481            .collect::<Result<_, _>>()?;
2482        let plan = squash_plan(&files, &applied)?;
2483
2484        for (file, decision) in files.iter().zip(plan) {
2485            if decision == ApplyDecision::Skip {
2486                continue;
2487            }
2488            // Belt-and-braces: skip a file whose declared plugin is shared.
2489            if shared_apps.contains(&file.plugin) {
2490                continue;
2491            }
2492
2493            let mut tx = pool.begin().await?;
2494            // Pin search_path for THIS transaction, tenant schema FIRST with
2495            // `public` as a fallback. `CREATE TABLE` / `INSERT` still land in
2496            // the tenant schema (it's first), but an unqualified reference that
2497            // ISN'T in the tenant schema resolves against `public` — which is
2498            // what makes a CROSS-BOUNDARY foreign key work: a tenant-owned
2499            // table (or an M2M junction) with an FK `REFERENCES <shared_child>`
2500            // resolves the shared child in `public` instead of erroring
2501            // `relation does not exist`. It also lets a (future) RunSql data
2502            // migration in a tenant schema read SHARED/`public` lookup tables.
2503            // The tenant-first ordering means a tenant table still shadows a
2504            // same-named public table, so no behaviour changes for the common
2505            // case where tenant and shared table names are distinct.
2506            sqlx::query(&format!("SET LOCAL search_path TO {quoted}, public"))
2507                .execute(&mut *tx)
2508                .await?;
2509            // RecordOnly: schema already built by the replaced originals — run
2510            // no DDL, just record the squash.
2511            if decision != ApplyDecision::RecordOnly {
2512                for op in &file.operations {
2513                    for sql in render_operation_for(op, "postgres") {
2514                        sqlx::query(&sql).execute(&mut *tx).await?;
2515                    }
2516                }
2517            }
2518            let snapshot_hash = file.snapshot_after.hash();
2519            let applied_at = chrono::Utc::now().to_rfc3339();
2520            sqlx::query(
2521                "INSERT INTO umbral_migrations (plugin, name, applied_at, snapshot_hash) \
2522                 VALUES ($1, $2, $3, $4)",
2523            )
2524            .bind(&file.plugin)
2525            .bind(&file.id)
2526            .bind(&applied_at)
2527            .bind(&snapshot_hash)
2528            .execute(&mut *tx)
2529            .await?;
2530            tx.commit().await?;
2531            applied_count += 1;
2532        }
2533    }
2534    Ok(applied_count)
2535}
2536
2537/// Migrate the **tenant** apps into the pool registered under `alias`
2538/// (database-per-tenant). The db-per-tenant sibling of [`run_for_schema`]:
2539/// where the schema variant pins `search_path` inside one shared Postgres
2540/// database, this targets a *whole separate database/pool* registered at
2541/// runtime via [`register_tenant_pool`](crate::db::register_tenant_pool) and
2542/// resolved here through [`pool_for_dispatched`](crate::db::pool_for_dispatched)
2543/// (which sees dynamic pools). No schema games — per-database migration
2544/// tracking is just that database's own `umbral_migrations` table.
2545///
2546/// Like the schema variant it applies only the **tenant apps**: every plugin
2547/// NOT in `shared_apps` (the shared registry/auth tables live in the default
2548/// DB and are migrated there by the normal [`run`]). A migration file whose
2549/// declared plugin is shared is skipped without a tracking row. Idempotent:
2550/// re-running applies only what the tenant DB's own ledger hasn't recorded.
2551///
2552/// Works on both backends — a tenant pool can be Postgres (the production case)
2553/// or SQLite (tests). Unlike the alias-routed [`run_in`], this does NOT filter
2554/// ops by [`table_alias`]: a tenant-owned model's static alias is still
2555/// `"default"`, so the per-alias filter would wrongly exclude it from the
2556/// tenant DB. The shared/tenant split is the *only* filter here.
2557pub async fn migrate_apps_into_pool(
2558    alias: &str,
2559    shared_apps: &std::collections::HashSet<String>,
2560) -> Result<u64, MigrateError> {
2561    migrate_apps_into_pool_in(Path::new(MIGRATIONS_DIR), alias, shared_apps).await
2562}
2563
2564/// Same as [`migrate_apps_into_pool`] but takes an explicit migrations base
2565/// directory. The entry tests drive.
2566pub async fn migrate_apps_into_pool_in(
2567    dir: &Path,
2568    alias: &str,
2569    shared_apps: &std::collections::HashSet<String>,
2570) -> Result<u64, MigrateError> {
2571    match crate::db::pool_for_dispatched(alias) {
2572        crate::db::DbPool::Postgres(p) => {
2573            migrate_tenant_apps_into_pg_pool(dir, shared_apps, p).await
2574        }
2575        crate::db::DbPool::Sqlite(p) => {
2576            migrate_tenant_apps_into_sqlite_pool(dir, shared_apps, p).await
2577        }
2578    }
2579}
2580
2581/// Postgres tenant-DB apply loop. Mirrors [`run_in_postgres_for_alias`] but the
2582/// only filter is the shared/tenant split — every plugin not in `shared_apps`
2583/// is applied in full into this database.
2584async fn migrate_tenant_apps_into_pg_pool(
2585    dir: &Path,
2586    shared_apps: &std::collections::HashSet<String>,
2587    pool: &sqlx::PgPool,
2588) -> Result<u64, MigrateError> {
2589    ensure_tracking_table_postgres(pool).await?;
2590    let applied = applied_names_postgres(pool).await?;
2591
2592    let mut applied_count: u64 = 0;
2593    for plugin in plugin_order() {
2594        if shared_apps.contains(&plugin) {
2595            continue;
2596        }
2597        let plugin_dir = dir.join(&plugin);
2598        let files: Vec<MigrationFile> = list_migration_files(&plugin_dir)?
2599            .iter()
2600            .map(|p| read_migration_file(p))
2601            .collect::<Result<_, _>>()?;
2602        let plan = squash_plan(&files, &applied)?;
2603        for (file, decision) in files.iter().zip(plan) {
2604            if decision == ApplyDecision::Skip {
2605                continue;
2606            }
2607            if shared_apps.contains(&file.plugin) {
2608                continue;
2609            }
2610            let mut tx = pool.begin().await?;
2611            if decision != ApplyDecision::RecordOnly {
2612                for op in &file.operations {
2613                    for sql in render_operation_for(op, "postgres") {
2614                        sqlx::query(&sql).execute(&mut *tx).await?;
2615                    }
2616                }
2617            }
2618            let snapshot_hash = file.snapshot_after.hash();
2619            let applied_at = chrono::Utc::now().to_rfc3339();
2620            sqlx::query(
2621                "INSERT INTO umbral_migrations (plugin, name, applied_at, snapshot_hash) \
2622                 VALUES ($1, $2, $3, $4)",
2623            )
2624            .bind(&file.plugin)
2625            .bind(&file.id)
2626            .bind(&applied_at)
2627            .bind(&snapshot_hash)
2628            .execute(&mut *tx)
2629            .await?;
2630            tx.commit().await?;
2631            applied_count += 1;
2632        }
2633    }
2634    Ok(applied_count)
2635}
2636
2637/// SQLite tenant-DB apply loop (tests). Same shape as the Postgres variant.
2638async fn migrate_tenant_apps_into_sqlite_pool(
2639    dir: &Path,
2640    shared_apps: &std::collections::HashSet<String>,
2641    pool: &sqlx::SqlitePool,
2642) -> Result<u64, MigrateError> {
2643    ensure_tracking_table_sqlite(pool).await?;
2644    let applied = applied_names_sqlite(pool).await?;
2645
2646    let mut applied_count: u64 = 0;
2647    for plugin in plugin_order() {
2648        if shared_apps.contains(&plugin) {
2649            continue;
2650        }
2651        let plugin_dir = dir.join(&plugin);
2652        let files: Vec<MigrationFile> = list_migration_files(&plugin_dir)?
2653            .iter()
2654            .map(|p| read_migration_file(p))
2655            .collect::<Result<_, _>>()?;
2656        let plan = squash_plan(&files, &applied)?;
2657        for (file, decision) in files.iter().zip(plan) {
2658            if decision == ApplyDecision::Skip {
2659                continue;
2660            }
2661            if shared_apps.contains(&file.plugin) {
2662                continue;
2663            }
2664            let snapshot_hash = file.snapshot_after.hash();
2665            let applied_at = chrono::Utc::now().to_rfc3339();
2666            // RecordOnly → empty op slice: inserts the tracking row, no DDL.
2667            let ops: Vec<&Operation> = if decision == ApplyDecision::RecordOnly {
2668                Vec::new()
2669            } else {
2670                file.operations.iter().collect()
2671            };
2672            apply_sqlite_migration_tx(
2673                pool,
2674                &ops,
2675                &file.plugin,
2676                &file.id,
2677                &applied_at,
2678                &snapshot_hash,
2679            )
2680            .await?;
2681            applied_count += 1;
2682        }
2683    }
2684    Ok(applied_count)
2685}
2686
2687/// `ensure_tracking_table_postgres` against an explicit connection (so the
2688/// caller can pin `search_path` first and have the table created in the tenant
2689/// schema rather than `public`).
2690async fn ensure_tracking_table_pg_conn(conn: &mut sqlx::PgConnection) -> Result<(), MigrateError> {
2691    sqlx::query(
2692        "CREATE TABLE IF NOT EXISTS umbral_migrations (
2693            plugin TEXT NOT NULL,
2694            name TEXT NOT NULL,
2695            applied_at TEXT NOT NULL,
2696            snapshot_hash TEXT NOT NULL,
2697            PRIMARY KEY (plugin, name)
2698        )",
2699    )
2700    .execute(conn)
2701    .await?;
2702    Ok(())
2703}
2704
2705/// SQLite drift-checking path for `run_checked_in`.
2706///
2707/// Reads the applied set, runs `detect_all_drift`, and either errors
2708/// (if `allow_drift = false` and critical drift is found) or logs a
2709/// warning and proceeds (if `allow_drift = true`). Then delegates to
2710/// `run_in_sqlite` for the actual apply loop.
2711async fn run_in_sqlite_checked(
2712    dir: &Path,
2713    pool: &sqlx::SqlitePool,
2714    allow_drift: bool,
2715    alias: &str,
2716) -> Result<u64, MigrateError> {
2717    ensure_tracking_table_sqlite(pool).await?;
2718    let applied = applied_names_sqlite(pool).await?;
2719    let report = detect_all_drift(&applied, dir)?;
2720
2721    if report.has_critical_drift() {
2722        if allow_drift {
2723            let missing = report.missing_on_disk();
2724            for entry in &missing {
2725                eprintln!(
2726                    "warning: umbral migrate --allow-drift: migration {}/{} is recorded in \
2727                     the tracking table but the file is missing from disk; proceeding.",
2728                    entry.plugin, entry.name
2729                );
2730            }
2731        } else {
2732            let missing: Vec<(String, String)> = report
2733                .missing_on_disk()
2734                .iter()
2735                .map(|e| (e.plugin.clone(), e.name.clone()))
2736                .collect();
2737            return Err(MigrateError::DriftDetected { missing });
2738        }
2739    }
2740
2741    // Emit warnings for out-of-order files.
2742    for entry in report
2743        .entries
2744        .iter()
2745        .filter(|e| e.status == MigrationStatus::OutOfOrder)
2746    {
2747        eprintln!(
2748            "warning: umbral migrate: migration {}/{} is on disk but appears before the \
2749             last applied migration for this plugin; it looks like a file was restored \
2750             after a teammate already applied later ones.",
2751            entry.plugin, entry.name
2752        );
2753    }
2754
2755    run_in_sqlite_for_alias(dir, alias, pool, None).await
2756}
2757
2758/// Postgres drift-checking path for `run_checked_in`. Same logic as
2759/// `run_in_sqlite_checked` but uses the Postgres applied-set reader.
2760async fn run_in_postgres_checked(
2761    dir: &Path,
2762    pool: &sqlx::PgPool,
2763    allow_drift: bool,
2764    alias: &str,
2765) -> Result<u64, MigrateError> {
2766    ensure_tracking_table_postgres(pool).await?;
2767    let applied = applied_names_postgres(pool).await?;
2768    let report = detect_all_drift(&applied, dir)?;
2769
2770    if report.has_critical_drift() {
2771        if allow_drift {
2772            let missing = report.missing_on_disk();
2773            for entry in &missing {
2774                eprintln!(
2775                    "warning: umbral migrate --allow-drift: migration {}/{} is recorded in \
2776                     the tracking table but the file is missing from disk; proceeding.",
2777                    entry.plugin, entry.name
2778                );
2779            }
2780        } else {
2781            let missing: Vec<(String, String)> = report
2782                .missing_on_disk()
2783                .iter()
2784                .map(|e| (e.plugin.clone(), e.name.clone()))
2785                .collect();
2786            return Err(MigrateError::DriftDetected { missing });
2787        }
2788    }
2789
2790    for entry in report
2791        .entries
2792        .iter()
2793        .filter(|e| e.status == MigrationStatus::OutOfOrder)
2794    {
2795        eprintln!(
2796            "warning: umbral migrate: migration {}/{} is on disk but appears before the \
2797             last applied migration for this plugin; it looks like a file was restored \
2798             after a teammate already applied later ones.",
2799            entry.plugin, entry.name
2800        );
2801    }
2802
2803    run_in_postgres_for_alias(dir, alias, pool, None).await
2804}
2805
2806/// Record a migration as applied in the `umbral_migrations` tracking
2807/// table without running its operations. The "mark as applied" path
2808/// `inspectdb --mark-applied` uses to register the introspected
2809/// `0001_initial` against an already-populated database. Idempotent:
2810/// if the `(plugin, name)` row already exists, the call is a no-op.
2811pub async fn record_applied(
2812    plugin: &str,
2813    name: &str,
2814    snapshot_hash: &str,
2815) -> Result<(), MigrateError> {
2816    let applied_at = chrono::Utc::now().to_rfc3339();
2817    match crate::db::pool_dispatched() {
2818        crate::db::DbPool::Sqlite(pool) => {
2819            ensure_tracking_table_sqlite(pool).await?;
2820            sqlx::query(
2821                "INSERT OR IGNORE INTO umbral_migrations \
2822                 (plugin, name, applied_at, snapshot_hash) \
2823                 VALUES (?, ?, ?, ?)",
2824            )
2825            .bind(plugin)
2826            .bind(name)
2827            .bind(&applied_at)
2828            .bind(snapshot_hash)
2829            .execute(pool)
2830            .await?;
2831        }
2832        crate::db::DbPool::Postgres(pool) => {
2833            ensure_tracking_table_postgres(pool).await?;
2834            sqlx::query(
2835                "INSERT INTO umbral_migrations \
2836                 (plugin, name, applied_at, snapshot_hash) \
2837                 VALUES ($1, $2, $3, $4) \
2838                 ON CONFLICT (plugin, name) DO NOTHING",
2839            )
2840            .bind(plugin)
2841            .bind(name)
2842            .bind(&applied_at)
2843            .bind(snapshot_hash)
2844            .execute(pool)
2845            .await?;
2846        }
2847    }
2848    Ok(())
2849}
2850
2851// =========================================================================
2852// Drift detection — gap 24.
2853// =========================================================================
2854
2855/// Compute the drift report for a single plugin directory. Compares the
2856/// set of `(plugin, name)` pairs recorded in the tracking table against
2857/// the migration files present on disk and classifies each into one of
2858/// the four [`MigrationStatus`] states.
2859///
2860/// `applied` is the full set of `(plugin, name)` tuples already read
2861/// from the tracking table (shared across plugins to avoid extra DB
2862/// round-trips). `plugin_dir` is the on-disk directory for this plugin;
2863/// an absent directory is treated the same as an empty one.
2864///
2865/// # Classification
2866///
2867/// - File present + in DB → `Applied`
2868/// - File absent + in DB → `AppliedButMissing`
2869/// - File present + not in DB + seq ≤ max_applied_seq → `OutOfOrder`
2870/// - File present + not in DB + seq > max_applied_seq → `Pending`
2871///
2872/// The sequence number is the numeric prefix of the migration name
2873/// (e.g. `0001` in `0001_create_post`). Absence of any applied
2874/// migration for this plugin means `max_applied_seq = 0`.
2875pub fn detect_drift(
2876    plugin: &str,
2877    applied: &std::collections::HashSet<(String, String)>,
2878    plugin_dir: &Path,
2879) -> Result<Vec<MigrationEntry>, MigrateError> {
2880    // Collect on-disk migration names (the id, not the full path).
2881    let paths = list_migration_files(plugin_dir)?;
2882    let mut on_disk: Vec<String> = Vec::new();
2883    for path in &paths {
2884        let file = read_migration_file(path)?;
2885        on_disk.push(file.id.clone());
2886    }
2887
2888    // Pull every tracking-table entry for this plugin.
2889    let plugin_applied: Vec<&str> = applied
2890        .iter()
2891        .filter(|(p, _)| p == plugin)
2892        .map(|(_, n)| n.as_str())
2893        .collect();
2894
2895    // Highest sequence number among applied migrations for this plugin.
2896    let max_applied_seq: u32 = plugin_applied
2897        .iter()
2898        .filter_map(|name| name.split('_').next()?.parse::<u32>().ok())
2899        .max()
2900        .unwrap_or(0);
2901
2902    let on_disk_set: std::collections::HashSet<&str> = on_disk.iter().map(|s| s.as_str()).collect();
2903
2904    let mut entries: Vec<MigrationEntry> = Vec::new();
2905
2906    // Walk on-disk files in order.
2907    for name in &on_disk {
2908        let key = (plugin.to_string(), name.clone());
2909        let status = if applied.contains(&key) {
2910            MigrationStatus::Applied
2911        } else {
2912            // Determine this migration's sequence number.
2913            let seq: u32 = name
2914                .split('_')
2915                .next()
2916                .and_then(|s| s.parse().ok())
2917                .unwrap_or(0);
2918            if seq <= max_applied_seq && max_applied_seq > 0 {
2919                MigrationStatus::OutOfOrder
2920            } else {
2921                MigrationStatus::Pending
2922            }
2923        };
2924        entries.push(MigrationEntry {
2925            plugin: plugin.to_string(),
2926            name: name.clone(),
2927            status,
2928        });
2929    }
2930
2931    // Walk applied entries not present on disk.
2932    for name in &plugin_applied {
2933        if !on_disk_set.contains(*name) {
2934            entries.push(MigrationEntry {
2935                plugin: plugin.to_string(),
2936                name: (*name).to_string(),
2937                status: MigrationStatus::AppliedButMissing,
2938            });
2939        }
2940    }
2941
2942    // Sort: applied-but-missing entries bubble after their expected
2943    // position is not determinable; sort all entries by name for a
2944    // deterministic order. In practice, applied-but-missing names
2945    // are still prefixed with the numeric sequence so lexical sort
2946    // yields the right display order.
2947    entries.sort_by(|a, b| a.name.cmp(&b.name));
2948
2949    Ok(entries)
2950}
2951
2952/// Detect drift across every registered plugin and return a combined
2953/// [`DriftReport`]. Called by `run_in_checked` before executing SQL
2954/// and by `show_in` when displaying the four-state list.
2955///
2956/// `applied` is already fetched from the DB; `dir` is the migrations
2957/// root directory.
2958pub fn detect_all_drift(
2959    applied: &std::collections::HashSet<(String, String)>,
2960    dir: &Path,
2961) -> Result<DriftReport, MigrateError> {
2962    let mut all_entries: Vec<MigrationEntry> = Vec::new();
2963
2964    // Also surface any tracking-table entries whose plugin directory
2965    // doesn't appear in the registered-plugins list — a plugin was
2966    // removed entirely but its DB rows remain.
2967    let mut seen_plugins: std::collections::HashSet<String> = std::collections::HashSet::new();
2968
2969    for plugin in plugin_order() {
2970        seen_plugins.insert(plugin.clone());
2971        let plugin_dir = dir.join(&plugin);
2972        let entries = detect_drift(&plugin, applied, &plugin_dir)?;
2973        all_entries.extend(entries);
2974    }
2975
2976    // Any applied entries whose plugin is not in the registered set at
2977    // all — treat them as AppliedButMissing (the whole plugin is gone).
2978    for (plugin, name) in applied {
2979        if !seen_plugins.contains(plugin.as_str()) {
2980            all_entries.push(MigrationEntry {
2981                plugin: plugin.clone(),
2982                name: name.clone(),
2983                status: MigrationStatus::AppliedButMissing,
2984            });
2985        }
2986    }
2987
2988    Ok(DriftReport {
2989        entries: all_entries,
2990    })
2991}
2992
2993/// Record a migration as applied in the tracking table WITHOUT running
2994/// its SQL operations. The `--fake` recovery path: the schema already
2995/// exists (e.g. the migration was run outside umbral, or the DB was
2996/// bootstrapped from a dump) and the operator wants to bring the
2997/// tracking table into sync without re-executing the DDL.
2998///
2999/// Idempotent: if `(plugin, name)` is already in the table the call
3000/// is a no-op (same behaviour as `record_applied`).
3001///
3002/// The snapshot hash is derived from the migration file on disk.
3003/// Returns `MigrateError::Io` if the file can't be found (the caller
3004/// should verify the name before calling this).
3005pub async fn fake_apply(plugin: &str, name: &str) -> Result<(), MigrateError> {
3006    fake_apply_in(plugin, name, Path::new(MIGRATIONS_DIR)).await
3007}
3008
3009/// Same as [`fake_apply`] but takes an explicit migrations base dir.
3010/// Used by tests and by the CLI when `--migrations-dir` is passed.
3011pub async fn fake_apply_in(plugin: &str, name: &str, dir: &Path) -> Result<(), MigrateError> {
3012    let path = dir.join(plugin).join(format!("{name}.json"));
3013    let file = read_migration_file(&path)?;
3014    let snapshot_hash = file.snapshot_after.hash();
3015    record_applied(plugin, name, &snapshot_hash).await
3016}
3017
3018/// For every registered plugin's first migration (`0001_*`), check
3019/// whether the tables that migration would create already exist in the
3020/// database. If they do, fake-apply the migration (mark it applied
3021/// without running its SQL).
3022///
3023/// This is the `--fake-initial` path: the operator has a database
3024/// bootstrapped outside umbral (a dump restore, a manual `CREATE TABLE`,
3025/// or a previous schema manager) and wants to bring the tracking table
3026/// into sync so subsequent `migrate` calls apply only the genuine
3027/// deltas.
3028///
3029/// Returns the number of plugins whose `0001_*` migration was
3030/// fake-applied. Zero means either no `0001_*` file exists or the
3031/// target tables were absent (in which case normal `migrate` should be
3032/// run to create them).
3033pub async fn fake_initial() -> Result<u64, MigrateError> {
3034    fake_initial_in(Path::new(MIGRATIONS_DIR)).await
3035}
3036
3037/// Same as [`fake_initial`] but takes an explicit migrations base dir.
3038pub async fn fake_initial_in(dir: &Path) -> Result<u64, MigrateError> {
3039    match crate::db::pool_dispatched() {
3040        crate::db::DbPool::Sqlite(pool) => fake_initial_sqlite(dir, pool).await,
3041        crate::db::DbPool::Postgres(pool) => fake_initial_postgres(dir, pool).await,
3042    }
3043}
3044
3045/// SQLite path for [`fake_initial_in`].
3046async fn fake_initial_sqlite(dir: &Path, pool: &sqlx::SqlitePool) -> Result<u64, MigrateError> {
3047    ensure_tracking_table_sqlite(pool).await?;
3048    let applied = applied_names_sqlite(pool).await?;
3049    let mut count: u64 = 0;
3050
3051    for plugin in plugin_order() {
3052        let plugin_dir = dir.join(&plugin);
3053        let paths = list_migration_files(&plugin_dir)?;
3054
3055        // Find the first migration file (lowest sequence number).
3056        let first = paths.first();
3057        let first = match first {
3058            Some(p) => p,
3059            None => continue,
3060        };
3061        let file = read_migration_file(first)?;
3062
3063        // Skip if already applied.
3064        if applied.contains(&(file.plugin.clone(), file.id.clone())) {
3065            continue;
3066        }
3067
3068        // Check whether the tables the first migration would create
3069        // already exist in the database.
3070        let tables_to_create: Vec<&str> = file
3071            .operations
3072            .iter()
3073            .filter_map(|op| match op {
3074                Operation::CreateTable { table, .. } => Some(table.as_str()),
3075                _ => None,
3076            })
3077            .collect();
3078
3079        if tables_to_create.is_empty() {
3080            continue;
3081        }
3082
3083        // All tables present → fake-apply.
3084        let mut all_present = true;
3085        for table in &tables_to_create {
3086            let exists: Option<(String,)> =
3087                sqlx::query_as("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
3088                    .bind(*table)
3089                    .fetch_optional(pool)
3090                    .await?;
3091            if exists.is_none() {
3092                all_present = false;
3093                break;
3094            }
3095        }
3096
3097        if all_present {
3098            let snapshot_hash = file.snapshot_after.hash();
3099            let applied_at = chrono::Utc::now().to_rfc3339();
3100            sqlx::query(
3101                "INSERT OR IGNORE INTO umbral_migrations \
3102                 (plugin, name, applied_at, snapshot_hash) VALUES (?, ?, ?, ?)",
3103            )
3104            .bind(&file.plugin)
3105            .bind(&file.id)
3106            .bind(&applied_at)
3107            .bind(&snapshot_hash)
3108            .execute(pool)
3109            .await?;
3110            count += 1;
3111        }
3112    }
3113
3114    Ok(count)
3115}
3116
3117/// Postgres path for [`fake_initial_in`].
3118async fn fake_initial_postgres(dir: &Path, pool: &sqlx::PgPool) -> Result<u64, MigrateError> {
3119    ensure_tracking_table_postgres(pool).await?;
3120    let applied = applied_names_postgres(pool).await?;
3121    let mut count: u64 = 0;
3122
3123    for plugin in plugin_order() {
3124        let plugin_dir = dir.join(&plugin);
3125        let paths = list_migration_files(&plugin_dir)?;
3126
3127        let first = paths.first();
3128        let first = match first {
3129            Some(p) => p,
3130            None => continue,
3131        };
3132        let file = read_migration_file(first)?;
3133
3134        if applied.contains(&(file.plugin.clone(), file.id.clone())) {
3135            continue;
3136        }
3137
3138        let tables_to_create: Vec<&str> = file
3139            .operations
3140            .iter()
3141            .filter_map(|op| match op {
3142                Operation::CreateTable { table, .. } => Some(table.as_str()),
3143                _ => None,
3144            })
3145            .collect();
3146
3147        if tables_to_create.is_empty() {
3148            continue;
3149        }
3150
3151        let mut all_present = true;
3152        for table in &tables_to_create {
3153            let exists: Option<(String,)> = sqlx::query_as(
3154                "SELECT table_name FROM information_schema.tables \
3155                 WHERE table_schema = 'public' AND table_name = $1",
3156            )
3157            .bind(*table)
3158            .fetch_optional(pool)
3159            .await?;
3160            if exists.is_none() {
3161                all_present = false;
3162                break;
3163            }
3164        }
3165
3166        if all_present {
3167            let snapshot_hash = file.snapshot_after.hash();
3168            let applied_at = chrono::Utc::now().to_rfc3339();
3169            sqlx::query(
3170                "INSERT INTO umbral_migrations \
3171                 (plugin, name, applied_at, snapshot_hash) VALUES ($1, $2, $3, $4) \
3172                 ON CONFLICT (plugin, name) DO NOTHING",
3173            )
3174            .bind(&file.plugin)
3175            .bind(&file.id)
3176            .bind(&applied_at)
3177            .bind(&snapshot_hash)
3178            .execute(pool)
3179            .await?;
3180            count += 1;
3181        }
3182    }
3183
3184    Ok(count)
3185}
3186
3187/// Print the per-migration state, applied or pending. Output goes to
3188/// stdout; the return value is the count of pending migrations so a
3189/// CLI can `exit(n)` on need.
3190pub async fn show() -> Result<u64, MigrateError> {
3191    show_in(Path::new(MIGRATIONS_DIR)).await
3192}
3193
3194/// Same as [`show`] but takes an explicit base directory. Walks every
3195/// registered plugin in sorted-by-name order, printing one section per
3196/// plugin that owns at least one migration file; empty plugins are
3197/// skipped silently rather than emitting a bare header.
3198///
3199/// Four-state output (gap 24):
3200///
3201/// - `[X]` applied and file present on disk (normal)
3202/// - `[ ]` pending (on disk, not yet applied, sequence after last applied)
3203/// - `[!]` applied but missing on disk (drift — tracking table ahead of VCS)
3204/// - `[?]` on disk but out of order (sequence before last applied, not in DB)
3205pub async fn show_in(dir: &Path) -> Result<u64, MigrateError> {
3206    let applied = match crate::db::pool_dispatched() {
3207        crate::db::DbPool::Sqlite(pool) => {
3208            ensure_tracking_table_sqlite(pool).await?;
3209            applied_names_sqlite(pool).await?
3210        }
3211        crate::db::DbPool::Postgres(pool) => {
3212            ensure_tracking_table_postgres(pool).await?;
3213            applied_names_postgres(pool).await?
3214        }
3215    };
3216
3217    let report = detect_all_drift(&applied, dir)?;
3218
3219    // Group by plugin for display.
3220    let mut by_plugin: std::collections::BTreeMap<&str, Vec<&MigrationEntry>> =
3221        std::collections::BTreeMap::new();
3222    for entry in &report.entries {
3223        by_plugin
3224            .entry(entry.plugin.as_str())
3225            .or_default()
3226            .push(entry);
3227    }
3228
3229    let mut pending: u64 = 0;
3230    for (plugin, entries) in &by_plugin {
3231        if entries.is_empty() {
3232            continue;
3233        }
3234        println!("# plugin: {plugin}");
3235        for entry in entries {
3236            let marker = match entry.status {
3237                MigrationStatus::Applied => "[X]",
3238                MigrationStatus::Pending => {
3239                    pending += 1;
3240                    "[ ]"
3241                }
3242                MigrationStatus::AppliedButMissing => "[!]",
3243                MigrationStatus::OutOfOrder => "[?]",
3244            };
3245            println!("{marker} {}/{}", entry.plugin, entry.name);
3246        }
3247    }
3248    Ok(pending)
3249}
3250
3251/// Safety classification for a single pending migration operation.
3252///
3253/// Feature #65 (blue-green / zero-downtime). The `checkmigrations`
3254/// command walks every pending operation and tags it so an operator
3255/// deploying without a maintenance window can tell which changes are safe
3256/// under a rolling deploy (old and new code serving traffic at once) and
3257/// which need the expand-contract dance. This is advisory triage — the
3258/// engine still *applies* every op exactly as written; nothing here gates
3259/// `migrate`.
3260#[derive(Debug, Clone, PartialEq, Eq)]
3261pub enum OpSafety {
3262    /// Additive and backward-compatible — safe while old code still runs.
3263    Safe,
3264    /// Applies cleanly but can break still-running old code, lock a large
3265    /// table, or fail against unexpected production data. Review first.
3266    Warning(String),
3267    /// Destroys data or is irreversible; old code referencing the dropped
3268    /// surface errors immediately.
3269    Unsafe(String),
3270}
3271
3272impl OpSafety {
3273    /// The advisory reason for a `Warning` / `Unsafe`; empty for `Safe`.
3274    pub fn reason(&self) -> &str {
3275        match self {
3276            OpSafety::Safe => "",
3277            OpSafety::Warning(r) | OpSafety::Unsafe(r) => r,
3278        }
3279    }
3280
3281    /// True for the destructive / irreversible tier only.
3282    pub fn is_unsafe(&self) -> bool {
3283        matches!(self, OpSafety::Unsafe(_))
3284    }
3285
3286    /// True for the review-before-deploy tier only.
3287    pub fn is_warning(&self) -> bool {
3288        matches!(self, OpSafety::Warning(_))
3289    }
3290}
3291
3292/// One pending operation tagged with its [`OpSafety`] and the migration
3293/// that introduced it. The unit of output for `checkmigrations`.
3294#[derive(Debug, Clone)]
3295pub struct ClassifiedOp {
3296    pub plugin: String,
3297    pub migration: String,
3298    pub op: Operation,
3299    pub safety: OpSafety,
3300}
3301
3302/// Classify one operation for zero-downtime safety. Pure — no DB access,
3303/// no file reads — so it is trivially unit-testable and reused by both
3304/// the CLI report and any plugin that wants to gate its own deploys.
3305pub fn classify_operation(op: &Operation) -> OpSafety {
3306    match op {
3307        // Brand-new tables touch no existing rows and no old code reads
3308        // them yet.
3309        Operation::CreateTable { .. } | Operation::CreateM2MTable { .. } => OpSafety::Safe,
3310
3311        // Adding a column is additive — unless it's NOT NULL with no
3312        // default, in which case old code inserting a row without the
3313        // column fails. (The engine refuses such an add against a
3314        // populated SQLite table at apply time; this surfaces the same
3315        // hazard *before* the operator runs it, and for Postgres too.)
3316        Operation::AddColumn { table, column } => {
3317            if !column.nullable && column.default.is_empty() {
3318                OpSafety::Warning(format!(
3319                    "adds NOT NULL column `{}.{}` with no default — old code inserting without it will fail. Add it nullable (or with a default), backfill, then tighten",
3320                    table, column.name
3321                ))
3322            } else {
3323                OpSafety::Safe
3324            }
3325        }
3326
3327        // Destructive / irreversible: data loss the moment it runs.
3328        Operation::DropTable { table } => OpSafety::Unsafe(format!(
3329            "drops table `{table}` and every row in it — irreversible, and old code still reading it breaks. Stop using it, deploy, then drop in a later migration"
3330        )),
3331        Operation::DropM2MTable { junction_table } => OpSafety::Unsafe(format!(
3332            "drops join table `{junction_table}` and every row in it — irreversible"
3333        )),
3334        Operation::DropColumn { table, column } => OpSafety::Unsafe(format!(
3335            "drops column `{table}.{column}` and its data — old code reading it breaks. Expand-contract: stop writing it, deploy, then drop"
3336        )),
3337
3338        // Renames apply atomically in the DB but NOT atomically with a
3339        // code deploy: between the migration and the rollout, one of the
3340        // two code versions references the missing name.
3341        Operation::RenameTable { from, to } => OpSafety::Warning(format!(
3342            "renames table `{from}` → `{to}` — not atomic with a code deploy; old code references `{from}`. Expand-contract: add `{to}`, dual-write, switch, then drop `{from}`"
3343        )),
3344        Operation::RenameColumn {
3345            table, from, to, ..
3346        } => OpSafety::Warning(format!(
3347            "renames column `{table}.{from}` → `{to}` — old code references `{from}`. Expand-contract: add `{to}`, backfill, switch reads, then drop `{from}`"
3348        )),
3349
3350        // An alter can rewrite a column (table lock on large data) and a
3351        // nullable→NOT NULL tightening fails on existing NULLs.
3352        Operation::AlterColumn { table, column, .. } => OpSafety::Warning(format!(
3353            "alters column `{table}.{column}` — a type change rewrites the column (locks the table on large data) and a NOT NULL tightening fails on existing NULLs; verify against production data first"
3354        )),
3355
3356        // A hand-authored data migration runs arbitrary SQL — the
3357        // engine can't reason about its row impact, so flag it for
3358        // human review (it may rewrite or delete data, and re-running
3359        // the rollout while it's mid-flight can double-apply).
3360        Operation::RunSql { .. } => OpSafety::Warning(
3361            "runs a hand-authored data migration (raw SQL) — review its row impact, ensure it's idempotent or guarded, and verify it against production data first".to_string(),
3362        ),
3363
3364        // Adding a composite UNIQUE constraint fails at apply time if
3365        // existing rows already violate it — same hazard as a single-column
3366        // UNIQUE add. A plain (non-unique) index is purely additive.
3367        Operation::AddIndex {
3368            table,
3369            columns,
3370            unique: true,
3371        } => OpSafety::Warning(format!(
3372            "adds a composite UNIQUE constraint on `{table}` ({}) — fails on existing duplicate rows; de-duplicate first or the migration aborts",
3373            columns.join(", ")
3374        )),
3375        Operation::AddIndex { unique: false, .. } => OpSafety::Safe,
3376
3377        // Dropping an index / UNIQUE constraint touches no rows. It removes
3378        // a guarantee (a later duplicate becomes insertable) but that is the
3379        // intent when a `unique_together` is removed, and no data is lost.
3380        Operation::DropIndex { .. } => OpSafety::Safe,
3381    }
3382}
3383
3384/// Classify every operation across all pending migrations against the
3385/// ambient pool. Reads the same applied-set + on-disk diff that
3386/// `migrate` / `showmigrations` use, then loads each pending migration
3387/// file and classifies its operations in order. Powers `checkmigrations`.
3388pub async fn check_pending_safety() -> Result<Vec<ClassifiedOp>, MigrateError> {
3389    check_pending_safety_in(Path::new(MIGRATIONS_DIR)).await
3390}
3391
3392/// [`check_pending_safety`] against an explicit migrations directory.
3393/// The seam tests use to point at a fixture tree.
3394pub async fn check_pending_safety_in(dir: &Path) -> Result<Vec<ClassifiedOp>, MigrateError> {
3395    let applied = match crate::db::pool_dispatched() {
3396        crate::db::DbPool::Sqlite(pool) => {
3397            ensure_tracking_table_sqlite(pool).await?;
3398            applied_names_sqlite(pool).await?
3399        }
3400        crate::db::DbPool::Postgres(pool) => {
3401            ensure_tracking_table_postgres(pool).await?;
3402            applied_names_postgres(pool).await?
3403        }
3404    };
3405
3406    let report = detect_all_drift(&applied, dir)?;
3407
3408    let mut out: Vec<ClassifiedOp> = Vec::new();
3409    for entry in &report.entries {
3410        if entry.status != MigrationStatus::Pending {
3411            continue;
3412        }
3413        let path = dir.join(&entry.plugin).join(format!("{}.json", entry.name));
3414        let file = read_migration_file(&path)?;
3415        for op in &file.operations {
3416            out.push(ClassifiedOp {
3417                plugin: entry.plugin.clone(),
3418                migration: entry.name.clone(),
3419                op: op.clone(),
3420                safety: classify_operation(op),
3421            });
3422        }
3423    }
3424    Ok(out)
3425}
3426
3427// =========================================================================
3428// Internal helpers. Crate-private; the public surface above is the only
3429// thing the rest of umbral calls into.
3430// =========================================================================
3431
3432/// Return every `*.json` migration file in `plugin_dir`, sorted by
3433/// filename (lexical sort matches numeric order because the prefix is
3434/// zero-padded). Returns an empty vec if the directory is missing.
3435fn list_migration_files(plugin_dir: &Path) -> Result<Vec<PathBuf>, MigrateError> {
3436    if !plugin_dir.exists() {
3437        return Ok(Vec::new());
3438    }
3439    let mut paths: Vec<PathBuf> = Vec::new();
3440    for entry in std::fs::read_dir(plugin_dir)? {
3441        let entry = entry?;
3442        let path = entry.path();
3443        if path.extension().and_then(|s| s.to_str()) == Some("json") {
3444            paths.push(path);
3445        }
3446    }
3447    paths.sort();
3448    Ok(paths)
3449}
3450
3451/// Read and parse one migration file.
3452fn read_migration_file(path: &Path) -> Result<MigrationFile, MigrateError> {
3453    let text = std::fs::read_to_string(path)?;
3454    let file: MigrationFile = serde_json::from_str(&text)?;
3455    Ok(file)
3456}
3457
3458/// Diff the previous snapshot against the current one and produce the
3459/// ordered operation list.
3460///
3461/// Emits `CreateTable` / `DropTable` for whole-model changes (M5 v1),
3462/// and `AddColumn` / `DropColumn` for column-level changes on a model
3463/// that appears in both snapshots (M8 v1). A column whose name stays
3464/// the same but whose type or nullable flag changed surfaces as
3465/// [`MigrateError::UnsafeAlter`]: SQLite can't ALTER COLUMN TYPE in
3466/// place, and a nullable flip on a populated table is destructive.
3467///
3468/// Gap 30 adds two-pass rename detection. `Model::NAME` (the Rust struct
3469/// name) is the stable identity key across snapshots; the SQL table name
3470/// in `Model::TABLE` may change (e.g. via the `#[umbral(plugin = "...")]`
3471/// opt-in). The two passes are:
3472///
3473/// - **First pass — struct-name match.** If a model present in `current`
3474///   but absent from `previous` (by `Model::NAME`) has the same NAME as
3475///   a model present in `previous` but absent from `current`, the table
3476///   name changed: emit `RenameTable { from, to }` instead of DropTable +
3477///   CreateTable. A stdout message names the rename so the developer can
3478///   audit `makemigrations` output.
3479/// - **Second pass — column-shape match.** Among unpaired drops and
3480///   creates, if a drop candidate and a create candidate have bit-identical
3481///   column shapes (same column names, types, nullable, fk_target), emit
3482///   `RenameTable` and log a warning so the developer can verify the
3483///   intent. Struct names differ; the shape heuristic fills in for cases
3484///   like a wholesale model rename (Foo → Bar, identical fields).
3485/// - **No-match.** Drop and create as today.
3486///
3487/// `pub` (not `pub(crate)`) so integration tests can drive the diff
3488/// directly with hand-built snapshots. Spec 06 calls the diff the
3489/// engine's contract; exposing it lets the tests pin every scenario
3490/// without laundering snapshots through the process-wide registry.
3491/// audit_2 H23 — how `diff` should treat an ambiguous column-shape rename: an
3492/// unpaired dropped model and an unpaired created model with *identical* shapes.
3493/// Driven by `UMBRAL_MIGRATIONS_ASSUME_RENAMES`.
3494enum RenameIntent {
3495    /// Auto-pair every shape match into a `RenameTable` (the pre-H23 default).
3496    Assume,
3497    /// Treat the pair as unrelated: emit drop + create, no row transfer.
3498    Independent,
3499    /// Not configured → `diff` fails closed with `AmbiguousRename`.
3500    Undecided,
3501}
3502
3503fn rename_intent() -> RenameIntent {
3504    match std::env::var("UMBRAL_MIGRATIONS_ASSUME_RENAMES") {
3505        Ok(v) => match v.trim().to_ascii_lowercase().as_str() {
3506            "assume" | "1" | "true" | "yes" | "rename" => RenameIntent::Assume,
3507            "independent" | "0" | "false" | "no" | "drop" => RenameIntent::Independent,
3508            _ => RenameIntent::Undecided,
3509        },
3510        Err(_) => RenameIntent::Undecided,
3511    }
3512}
3513
3514/// Emit `AddIndex` / `DropIndex` ops for changes to a model's TABLE-level
3515/// `unique_together` and `indexes` between two snapshots.
3516///
3517/// Column-shape changes are handled by [`diff_columns`]; this covers the
3518/// constraint-only deltas it can't see. Before this existed, adding a
3519/// `unique_together` group or a multi-column `indexes` entry with no column
3520/// change produced NO migration at all — `makemigrations` said "no changes"
3521/// and the constraint was silently never created.
3522///
3523/// A group is identified by its ORDERED column list, so re-ordering a group
3524/// (`[a, b]` → `[b, a]`) reads as drop-old + add-new — correct, since column
3525/// order changes which queries the index serves. `AddIndex`/`DropIndex`
3526/// render `IF NOT EXISTS` / `IF EXISTS`, so when a same-table `AlterColumn`
3527/// in the same migration already rebuilt the table with the new constraint
3528/// set (the SQLite dance), these ops are harmless no-ops.
3529fn diff_indexes(previous: &ModelMeta, current: &ModelMeta) -> Vec<Operation> {
3530    use std::collections::BTreeSet;
3531
3532    let mut ops: Vec<Operation> = Vec::new();
3533
3534    let mut emit = |prev_groups: &[Vec<String>], curr_groups: &[Vec<String>], unique: bool| {
3535        let prev_set: BTreeSet<&Vec<String>> = prev_groups.iter().collect();
3536        let curr_set: BTreeSet<&Vec<String>> = curr_groups.iter().collect();
3537        // Added groups → AddIndex (on the current table name).
3538        for group in curr_set.difference(&prev_set) {
3539            ops.push(Operation::AddIndex {
3540                table: current.table.clone(),
3541                columns: (*group).clone(),
3542                unique,
3543            });
3544        }
3545        // Removed groups → DropIndex (on the previous table name — a rename
3546        // in the same diff emits its own RenameTable first, and the index
3547        // travels with the table, so the drop names the post-rename table;
3548        // use current.table for that reason).
3549        for group in prev_set.difference(&curr_set) {
3550            ops.push(Operation::DropIndex {
3551                table: current.table.clone(),
3552                columns: (*group).clone(),
3553                unique,
3554            });
3555        }
3556    };
3557
3558    emit(&previous.unique_together, &current.unique_together, true);
3559    emit(&previous.indexes, &current.indexes, false);
3560
3561    // Single-column `#[umbral(index)]` flag flips. A column that gains the
3562    // flag → AddIndex; one that loses it (but still exists) → DropIndex. PK
3563    // and UNIQUE columns are excluded — they carry their own index, matching
3564    // `should_emit_btree_index`. A column that was DROPPED entirely takes its
3565    // index with it (DROP COLUMN cascades on both backends), so we don't emit a
3566    // redundant DropIndex for it. The `idx_<table>_<col>` name matches the one
3567    // `create_index_stmt` uses at CreateTable time, so add/drop stay symmetric.
3568    let indexed = |m: &ModelMeta| -> std::collections::BTreeSet<String> {
3569        m.fields
3570            .iter()
3571            .filter(|c| c.index && !c.primary_key && !c.unique)
3572            .map(|c| c.name.clone())
3573            .collect()
3574    };
3575    let curr_col_names: std::collections::BTreeSet<&str> =
3576        current.fields.iter().map(|c| c.name.as_str()).collect();
3577    let prev_indexed = indexed(previous);
3578    let curr_indexed = indexed(current);
3579    for name in curr_indexed.difference(&prev_indexed) {
3580        ops.push(Operation::AddIndex {
3581            table: current.table.clone(),
3582            columns: vec![name.clone()],
3583            unique: false,
3584        });
3585    }
3586    for name in prev_indexed.difference(&curr_indexed) {
3587        if curr_col_names.contains(name.as_str()) {
3588            ops.push(Operation::DropIndex {
3589                table: current.table.clone(),
3590                columns: vec![name.clone()],
3591                unique: false,
3592            });
3593        }
3594    }
3595    ops
3596}
3597
3598pub fn diff(previous: &Snapshot, current: &Snapshot) -> Result<Vec<Operation>, MigrateError> {
3599    use std::collections::{BTreeMap, HashSet};
3600
3601    let prev_by_name: BTreeMap<&str, &ModelMeta> = previous
3602        .models
3603        .iter()
3604        .map(|m| (m.name.as_str(), m))
3605        .collect();
3606    let curr_by_name: BTreeMap<&str, &ModelMeta> = current
3607        .models
3608        .iter()
3609        .map(|m| (m.name.as_str(), m))
3610        .collect();
3611
3612    let mut ops: Vec<Operation> = Vec::new();
3613
3614    // gaps.md #93: every table rename `diff` decides on (old_table → new_table),
3615    // recorded so Pass 4 can rename the parent's M2M junctions instead of
3616    // dropping + recreating them (which would destroy every relationship row).
3617    let mut renamed_tables: BTreeMap<String, String> = BTreeMap::new();
3618
3619    // ---- Pass 0: Walk models present in both snapshots (same NAME). ----
3620    // Same-name models with a different table produce a first-pass rename.
3621    // Same-name models with identical table+columns produce nothing.
3622    // Same-name models with column changes produce column-level ops.
3623
3624    let mut drop_candidates: Vec<&ModelMeta> = Vec::new(); // in prev, not curr
3625    let mut create_candidates: Vec<&ModelMeta> = Vec::new(); // in curr, not prev
3626
3627    // Creates and column-level diffs, in deterministic name order.
3628    for (name, curr) in &curr_by_name {
3629        match prev_by_name.get(name) {
3630            None => {
3631                // In current but not previous — might be a create or a first-pass rename.
3632                create_candidates.push(curr);
3633            }
3634            Some(prev) if prev.table != curr.table => {
3635                // Same struct name, different table name → first-pass rename.
3636                println!(
3637                    "umbral makemigrations: rename detected (struct-name match): \
3638                     table `{}` → `{}`",
3639                    prev.table, curr.table
3640                );
3641                ops.push(Operation::RenameTable {
3642                    from: prev.table.clone(),
3643                    to: curr.table.clone(),
3644                });
3645                renamed_tables.insert(prev.table.clone(), curr.table.clone());
3646                // After the rename the columns might also have changed; diff them.
3647                let col_ops = diff_columns(name, prev, curr)?;
3648                ops.extend(col_ops);
3649                // ...and the table-level constraints (unique_together/indexes).
3650                ops.extend(diff_indexes(prev, curr));
3651            }
3652            Some(prev) if prev == curr => {}
3653            Some(prev) => {
3654                ops.extend(diff_columns(name, prev, curr)?);
3655                // Constraint-only deltas (a new/removed unique_together or
3656                // composite index with no column change) — otherwise silent.
3657                ops.extend(diff_indexes(prev, curr));
3658            }
3659        }
3660    }
3661
3662    // Drops — models in prev but not curr (by NAME).
3663    for (name, prev) in &prev_by_name {
3664        if !curr_by_name.contains_key(name) {
3665            drop_candidates.push(prev);
3666        }
3667    }
3668
3669    // ---- Pass 1: Column-shape heuristic for unpaired drops + creates. ----
3670    // A sorted, canonical serialisation of (name, ty, nullable, fk_target)
3671    // is the "shape" fingerprint. Bit-identical shapes → likely a model
3672    // rename where the struct name also changed.
3673
3674    // audit_2 H23 — a bit-identical column shape between a dropped and a
3675    // created model is genuinely ambiguous (rename vs. two unrelated models).
3676    // The pre-H23 code auto-emitted a `RenameTable` with only an `eprintln!`,
3677    // silently handing one model's rows to another and skipping the intended
3678    // drop. Now the ambiguity is resolved by explicit operator intent: `assume`
3679    // restores auto-pairing, `independent` treats the pair as unrelated
3680    // (drop + create), and the unset default fails closed so no destructive
3681    // guess is ever applied silently.
3682    let intent = rename_intent();
3683    let mut paired_drop_tables: HashSet<&str> = HashSet::new();
3684    let mut paired_create_tables: HashSet<&str> = HashSet::new();
3685
3686    'creates: for create in &create_candidates {
3687        let create_shape = column_shape(&create.fields);
3688        for drop in &drop_candidates {
3689            if paired_drop_tables.contains(drop.table.as_str()) {
3690                continue;
3691            }
3692            if column_shape(&drop.fields) != create_shape {
3693                continue;
3694            }
3695            match intent {
3696                RenameIntent::Assume => {
3697                    eprintln!(
3698                        "umbral makemigrations: rename ASSUMED (column-shape match): \
3699                         `{}` → `{}` — moving the old table's rows to the new name. Set \
3700                         UMBRAL_MIGRATIONS_ASSUME_RENAMES=independent if these are unrelated \
3701                         models that merely share a shape.",
3702                        drop.table, create.table
3703                    );
3704                    ops.push(Operation::RenameTable {
3705                        from: drop.table.clone(),
3706                        to: create.table.clone(),
3707                    });
3708                    renamed_tables.insert(drop.table.clone(), create.table.clone());
3709                    paired_drop_tables.insert(drop.table.as_str());
3710                    paired_create_tables.insert(create.table.as_str());
3711                    continue 'creates;
3712                }
3713                RenameIntent::Independent => {
3714                    eprintln!(
3715                        "umbral makemigrations: column-shape match `{}` ↔ `{}` treated as \
3716                         UNRELATED (drop + create) per UMBRAL_MIGRATIONS_ASSUME_RENAMES=\
3717                         independent. Set it to `assume` (or hand-write a RenameTable) if this \
3718                         is actually a rename — otherwise `{}`'s rows are dropped.",
3719                        drop.table, create.table, drop.table
3720                    );
3721                    // Leave both unpaired → Pass 2 creates, Pass 3 drops.
3722                    continue 'creates;
3723                }
3724                RenameIntent::Undecided => {
3725                    return Err(MigrateError::AmbiguousRename {
3726                        from_table: drop.table.clone(),
3727                        to_table: create.table.clone(),
3728                    });
3729                }
3730            }
3731        }
3732    }
3733
3734    // ---- Pass 2: Emit plain CreateTable for unpaired creates. ----
3735    //
3736    // Sort the create list topologically by FK dependency so that a
3737    // table referenced by another table in this batch is created first.
3738    // Without this, Postgres rejects the second CreateTable with
3739    // `relation "<target>" does not exist`. (SQLite tolerates the wrong
3740    // order when `foreign_keys=OFF`, the historical default; once
3741    // we turned foreign_keys ON in connect_sqlite, SQLite agrees with
3742    // Postgres on the order requirement.)
3743    //
3744    // Kahn's algorithm on (table → set of FK-target tables that are
3745    // ALSO in the create batch). Self-references and FK targets outside
3746    // the batch are skipped (they're either harmless or already exist
3747    // by the time this migration runs).
3748    let creates: Vec<&&ModelMeta> = create_candidates
3749        .iter()
3750        .filter(|c| !paired_create_tables.contains(c.table.as_str()))
3751        .collect();
3752    let batch_tables: HashSet<&str> = creates.iter().map(|c| c.table.as_str()).collect();
3753    let mut deps: BTreeMap<&str, HashSet<&str>> = BTreeMap::new();
3754    for create in &creates {
3755        let mut in_batch: HashSet<&str> = HashSet::new();
3756        for col in &create.fields {
3757            if let Some(target) = col.fk_target.as_deref()
3758                && target != create.table.as_str()
3759                && batch_tables.contains(target)
3760            {
3761                in_batch.insert(target);
3762            }
3763        }
3764        deps.insert(create.table.as_str(), in_batch);
3765    }
3766    // Kahn: repeatedly pop tables with no remaining deps in the batch.
3767    // BTreeMap iteration is alphabetical → ties break alphabetically,
3768    // keeping the output stable.
3769    let mut ordered: Vec<&&ModelMeta> = Vec::with_capacity(creates.len());
3770    while !deps.is_empty() {
3771        let ready: Vec<&str> = deps
3772            .iter()
3773            .filter(|(_, d)| d.is_empty())
3774            .map(|(t, _)| *t)
3775            .collect();
3776        if ready.is_empty() {
3777            // Cyclic FK or other unresolvable dep — fall through to
3778            // the original order rather than dropping models. A cycle
3779            // here means the user's schema can't be created with
3780            // plain CreateTable anyway (Postgres needs deferrable
3781            // constraints), so we surface the user-visible error at
3782            // apply time instead of silently looping.
3783            for create in &creates {
3784                if deps.contains_key(create.table.as_str()) {
3785                    ordered.push(create);
3786                }
3787            }
3788            break;
3789        }
3790        for t in &ready {
3791            if let Some(create) = creates.iter().find(|c| c.table.as_str() == *t) {
3792                ordered.push(create);
3793            }
3794            deps.remove(t);
3795        }
3796        for (_, set) in deps.iter_mut() {
3797            for t in &ready {
3798                set.remove(t);
3799            }
3800        }
3801    }
3802    for create in ordered {
3803        ops.push(Operation::CreateTable {
3804            table: create.table.clone(),
3805            columns: create.fields.clone(),
3806            unique_together: create.unique_together.clone(),
3807            indexes: create.indexes.clone(),
3808        });
3809    }
3810
3811    // ---- Pass 3: Emit plain DropTable for unpaired drops. ----
3812    for drop in &drop_candidates {
3813        if !paired_drop_tables.contains(drop.table.as_str()) {
3814            ops.push(Operation::DropTable {
3815                table: drop.table.clone(),
3816            });
3817        }
3818    }
3819
3820    // ---- Pass 4: Diff M2M relations. Closes the remaining BUG-16 gap. ----
3821    //
3822    // Treat each (parent_table, field_name) pair as a junction-table identity.
3823    // Compare the flattened set across snapshots and emit CreateM2MTable /
3824    // DropM2MTable per delta.
3825    //
3826    // gaps.md #93: when the PARENT model was renamed (Pass 0/1), its junction's
3827    // key moves from `(old_table, field)` to `(new_table, field)` — which would
3828    // otherwise read as one junction dropped and a different one created,
3829    // destroying every relationship row. Detect that case via `renamed_tables`
3830    // and emit a plain `RenameTable` on the junction instead. The junction's
3831    // columns are generic (`parent_id`/`child_id`) and its FK to the parent is
3832    // auto-updated by the parent's own rename, so a table rename is sufficient.
3833    let prev_m2m = collect_m2m_pairs(previous);
3834    let curr_m2m = collect_m2m_pairs(current);
3835
3836    // Prev junction keys consumed by a junction rename below — skip their drop.
3837    let mut renamed_prev_junctions: HashSet<(String, String)> = HashSet::new();
3838
3839    for (key, spec) in &curr_m2m {
3840        if prev_m2m.contains_key(key) {
3841            continue;
3842        }
3843        let (new_parent, field) = key;
3844        // Was this junction's parent renamed FROM some old table? If the prior
3845        // snapshot has the same field on that old parent, targeting the same
3846        // table, it's a junction rename, not a fresh create.
3847        let renamed_from = renamed_tables
3848            .iter()
3849            .find(|(_, new)| *new == new_parent)
3850            .and_then(|(old, _)| {
3851                let old_key = (old.clone(), field.clone());
3852                prev_m2m.get(&old_key).and_then(|old_spec| {
3853                    (old_spec.target_table == spec.target_table).then_some(old_key)
3854                })
3855            });
3856        if let Some(old_key) = renamed_from {
3857            let old_junction = prev_m2m[&old_key].junction_table.clone();
3858            ops.push(Operation::RenameTable {
3859                from: old_junction,
3860                to: spec.junction_table.clone(),
3861            });
3862            renamed_prev_junctions.insert(old_key);
3863            continue;
3864        }
3865        // New M2M field on an existing or new model. Resolve the
3866        // target's PK column from the current snapshot.
3867        match build_create_m2m_op(spec, current) {
3868            Ok(op) => ops.push(op),
3869            Err(e) => return Err(e),
3870        }
3871    }
3872    for (key, spec) in &prev_m2m {
3873        if curr_m2m.contains_key(key) || renamed_prev_junctions.contains(key) {
3874            continue;
3875        }
3876        // M2M field removed (or its parent was dropped). The junction
3877        // table goes away.
3878        ops.push(Operation::DropM2MTable {
3879            junction_table: spec.junction_table.clone(),
3880        });
3881    }
3882
3883    Ok(ops)
3884}
3885
3886/// A flat-resolved M2M descriptor used by [`diff`] to compare snapshots.
3887/// Owns its strings so it can be keyed in a map without lifetime
3888/// gymnastics.
3889#[derive(Debug, Clone)]
3890struct M2MPair {
3891    parent_table: String,
3892    parent_pk: String,
3893    field_name: String,
3894    target_table: String,
3895    junction_table: String,
3896}
3897
3898/// Walk a snapshot and produce one [`M2MPair`] per declared M2M field.
3899/// Keyed on `(parent_table, field_name)` since that uniquely identifies
3900/// a junction table — two models can't share the same parent_table, and
3901/// one model can't declare two M2M fields with the same name.
3902fn collect_m2m_pairs(snap: &Snapshot) -> std::collections::BTreeMap<(String, String), M2MPair> {
3903    let mut out = std::collections::BTreeMap::new();
3904    for model in &snap.models {
3905        let parent_pk = model
3906            .fields
3907            .iter()
3908            .find(|c| c.primary_key)
3909            .map(|c| c.name.clone())
3910            .unwrap_or_else(|| "id".to_string());
3911        for rel in &model.m2m_relations {
3912            let key = (model.table.clone(), rel.field_name.clone());
3913            out.insert(
3914                key,
3915                M2MPair {
3916                    parent_table: model.table.clone(),
3917                    parent_pk: parent_pk.clone(),
3918                    field_name: rel.field_name.clone(),
3919                    target_table: rel.target_table.clone(),
3920                    junction_table: format!("{}_{}", model.table, rel.field_name),
3921                },
3922            );
3923        }
3924    }
3925    out
3926}
3927
3928/// Lift an [`M2MPair`] into a fully-specified [`Operation::CreateM2MTable`].
3929/// The target table's PK column name is resolved from `current` (the
3930/// snapshot the diff is computing toward) — without it the DDL would
3931/// reference a column the child table doesn't have.
3932fn build_create_m2m_op(spec: &M2MPair, current: &Snapshot) -> Result<Operation, MigrateError> {
3933    // Resolve the target's PK from the current snapshot, FALLING BACK to the
3934    // global model registry. Migrations are generated per-plugin, so a
3935    // CROSS-PLUGIN M2M (parent owned by app A, target model owned by app B —
3936    // e.g. a tenant model with an M2M to a SHARED lookup table, or any app's
3937    // M2M to `umbral-auth`'s `User`) has its target in a *different* plugin's
3938    // snapshot, absent from `current`. The global registry sees every
3939    // registered model, so the junction DDL resolves the child PK no matter
3940    // which plugin owns the target. (Cross-plugin FK ordering already lets the
3941    // junction migration run after the target table's own migration.)
3942    let pk_col_and_ty = |m: &ModelMeta| -> (String, crate::orm::SqlType) {
3943        let pk = m.fields.iter().find(|c| c.primary_key);
3944        (
3945            pk.map(|c| c.name.clone())
3946                .unwrap_or_else(|| "id".to_string()),
3947            pk.map(|c| c.ty).unwrap_or(crate::orm::SqlType::BigInt),
3948        )
3949    };
3950    let (child_pk_col, child_ty) = current
3951        .models
3952        .iter()
3953        .find(|m| m.table == spec.target_table)
3954        .map(|m| pk_col_and_ty(m))
3955        .or_else(|| {
3956            // Non-panicking global lookup. `registered_models()` panics if the
3957            // registry isn't initialised (unit tests that call `diff` directly,
3958            // with no `App::build`); a `None` registry simply yields no global
3959            // fallback, so a TRULY-unregistered target is still rejected below.
3960            REGISTRY.get().and_then(|reg| {
3961                reg.iter()
3962                    .find(|(_, m)| m.table == spec.target_table)
3963                    .map(|(_, m)| pk_col_and_ty(m))
3964            })
3965        })
3966        .ok_or_else(|| {
3967            MigrateError::UnsupportedChange(format!(
3968                "M2M `{}.{}` targets table `{}` which is not registered \
3969                 anywhere — register the target model via \
3970                 `AppBuilder::model::<{}>()` or its owning plugin.",
3971                spec.parent_table, spec.field_name, spec.target_table, spec.target_table,
3972            ))
3973        })?;
3974    let parent_model = current
3975        .models
3976        .iter()
3977        .find(|m| m.table == spec.parent_table)
3978        .expect("parent model exists in snapshot — collect_m2m_pairs iterated it");
3979    let parent_ty = parent_model
3980        .fields
3981        .iter()
3982        .find(|c| c.primary_key)
3983        .map(|c| c.ty)
3984        .unwrap_or(crate::orm::SqlType::BigInt);
3985    Ok(Operation::CreateM2MTable {
3986        junction_table: spec.junction_table.clone(),
3987        parent_table: spec.parent_table.clone(),
3988        parent_col: spec.parent_pk.clone(),
3989        child_table: spec.target_table.clone(),
3990        child_col: child_pk_col,
3991        parent_ty,
3992        child_ty,
3993    })
3994}
3995
3996/// Compute a canonical, sorted column-shape fingerprint for rename
3997/// heuristic detection in `diff`. Two models whose column fingerprints
3998/// are identical are candidates for a rename (second-pass detection).
3999///
4000/// The fingerprint is a sorted `Vec` of `(name, ty, nullable, fk_target)`
4001/// tuples. Sorting by name ensures the fingerprint is independent of
4002/// declaration order.
4003fn column_shape(fields: &[Column]) -> Vec<(String, SqlType, bool, Option<String>)> {
4004    let mut shape: Vec<(String, SqlType, bool, Option<String>)> = fields
4005        .iter()
4006        .map(|c| (c.name.clone(), c.ty, c.nullable, c.fk_target.clone()))
4007        .collect();
4008    shape.sort_by(|a, b| a.0.cmp(&b.0));
4009    shape
4010}
4011
4012/// Type changes the migration engine can apply without user
4013/// intervention. The contract: every entry in this whitelist must be
4014/// data-preserving on both backends.
4015///
4016/// SQLite handles every entry trivially via the table-recreation
4017/// dance: its dynamic typing means whatever lives in a column today
4018/// reads back fine under a new column type affinity. Postgres needs
4019/// `ALTER COLUMN ... TYPE new_type USING column::new_type`, which the
4020/// renderer emits when this returns `true`.
4021///
4022/// What's *not* here is deliberate:
4023/// - `Text -> BigInt` / numeric parses can fail at runtime on non-
4024///   numeric rows. Force the user to write the migration so they own
4025///   the validation.
4026/// - Bigger int -> smaller int truncates silently.
4027/// - `Text -> Date` / `Text -> Uuid` are format-dependent.
4028/// - Anything -> JSON. Even if existing rows are JSON-shaped, that's
4029///   the user's invariant to assert.
4030fn is_safe_cast(from: SqlType, to: SqlType) -> bool {
4031    use SqlType::*;
4032    if from == to {
4033        return true;
4034    }
4035    match (from, to) {
4036        // Stringify: every scalar serialises to text losslessly. Read-
4037        // path code that wants the typed value parses it back; the
4038        // cast itself never fails.
4039        (
4040            SmallInt | Integer | BigInt | Real | Double | Boolean | Date | Time | Timestamptz
4041            | Uuid | Inet | Cidr | MacAddr | ForeignKey,
4042            Text,
4043        ) => true,
4044        // Integer widening — no data loss.
4045        (SmallInt, Integer | BigInt) => true,
4046        (Integer, BigInt) => true,
4047        // Float widening.
4048        (Real, Double) => true,
4049        // ForeignKey is stored as BigInt under the hood, so the two
4050        // directions are storage-identical. The Rust-side type is
4051        // different but the bytes on disk are not.
4052        (ForeignKey, BigInt) => true,
4053        (BigInt, ForeignKey) => true,
4054        _ => false,
4055    }
4056}
4057
4058/// Postgres type name for an `ALTER COLUMN ... TYPE <name> USING …`
4059/// clause. Matches what sea-query's `PostgresQueryBuilder` emits for
4060/// the same `SqlType` inside a `CREATE TABLE`, so the resulting
4061/// schema after the alter is identical to a freshly created table.
4062fn postgres_type_name(ty: SqlType) -> &'static str {
4063    use SqlType::*;
4064    match ty {
4065        SmallInt => "smallint",
4066        Integer => "integer",
4067        BigInt | ForeignKey => "bigint",
4068        Real => "real",
4069        Double => "double precision",
4070        Boolean => "boolean",
4071        Text => "text",
4072        Date => "date",
4073        Time => "time",
4074        // sea-query's Postgres builder emits `timestamp with time zone`
4075        // for the equivalent column type; both spellings are accepted
4076        // by Postgres, but mirroring the builder keeps the surface
4077        // consistent if a test ever round-trips DDL.
4078        Timestamptz => "timestamp with time zone",
4079        Uuid => "uuid",
4080        Json => "jsonb",
4081        Inet => "inet",
4082        Cidr => "cidr",
4083        MacAddr => "macaddr",
4084        // gaps2 #70: text-backed Postgres types. `bit varying` mirrors
4085        // what sea-query's builder emits for the CREATE TABLE path.
4086        Xml => "xml",
4087        Ltree => "ltree",
4088        Bit => "bit varying",
4089        FullText => "tsvector",
4090        Bytes => "bytea",
4091        // BUG-10: NUMERIC(19, 4) — same dimensions as the CREATE TABLE
4092        // build path. Used by the `ALTER COLUMN ... TYPE ...` render
4093        // when the safe-cast diff allows transitioning to/from
4094        // Decimal.
4095        Decimal => "numeric(19, 4)",
4096        // Arrays render as `<inner>[]` in Postgres. The migration
4097        // engine doesn't model nested element types deeply enough to
4098        // emit a precise inner type here at v1; fall back to `text[]`
4099        // and rely on the column-def renderer for the real shape when
4100        // recreating the column.
4101        Array(_) => "text[]",
4102    }
4103}
4104
4105/// Per-model column diff. Same-name columns whose type or nullable
4106/// flag changed return `UnsafeAlter` (no `AlterColumn` until M8 v1.1
4107/// covers the table-recreation dance for SQLite plus native ALTER for
4108/// Postgres). New-named columns emit `AddColumn`; missing-name columns
4109/// emit `DropColumn`. The ordering is: drops first, then adds, so a
4110/// rename-as-drop+add doesn't violate a uniqueness constraint mid-
4111/// migration on a single-row table.
4112fn diff_columns(
4113    model: &str,
4114    previous: &ModelMeta,
4115    current: &ModelMeta,
4116) -> Result<Vec<Operation>, MigrateError> {
4117    use std::collections::BTreeMap;
4118
4119    let prev_cols: BTreeMap<&str, &Column> = previous
4120        .fields
4121        .iter()
4122        .map(|c| (c.name.as_str(), c))
4123        .collect();
4124    let curr_cols: BTreeMap<&str, &Column> = current
4125        .fields
4126        .iter()
4127        .map(|c| (c.name.as_str(), c))
4128        .collect();
4129
4130    // Walk the intersection by name. Two questions per shared column:
4131    //   - did the type change? If so, is the change in the safe-cast
4132    //     whitelist (e.g. BigInt -> Text, SmallInt -> Integer)? Safe
4133    //     casts emit AlterColumn; unsafe ones still UnsafeAlter so the
4134    //     user is forced to write the data-preserving migration by
4135    //     hand.
4136    //   - did the nullable flag flip? AlterColumn either way.
4137    // Primary-key changes still UnsafeAlter (a PK rebuild is its own
4138    // dance and isn't shipped yet).
4139    let mut alter_columns: Vec<&str> = Vec::new();
4140    for (name, prev_col) in &prev_cols {
4141        if let Some(curr_col) = curr_cols.get(name) {
4142            if prev_col.primary_key != curr_col.primary_key {
4143                return Err(MigrateError::UnsafeAlter {
4144                    model: model.to_string(),
4145                    column: (*name).to_string(),
4146                    reason: "primary-key flips need a manual data-preserving migration".to_string(),
4147                });
4148            }
4149            let type_changed = prev_col.ty != curr_col.ty;
4150            if type_changed && !is_safe_cast(prev_col.ty, curr_col.ty) {
4151                return Err(MigrateError::UnsafeAlter {
4152                    model: model.to_string(),
4153                    column: (*name).to_string(),
4154                    reason: format!(
4155                        "type change {prev_ty:?} -> {curr_ty:?} is not in the safe-cast whitelist — write a data-preserving migration by hand",
4156                        prev_ty = prev_col.ty,
4157                        curr_ty = curr_col.ty,
4158                    ),
4159                });
4160            }
4161            if prev_col.nullable && !curr_col.nullable && curr_col.default.is_empty() {
4162                return Err(MigrateError::UnsafeAlter {
4163                    model: model.to_string(),
4164                    column: (*name).to_string(),
4165                    reason: "nullable → NOT NULL requires a default/backfill before tightening; otherwise existing NULL rows abort the migration".to_string(),
4166                });
4167            }
4168            if !prev_col.unique && curr_col.unique {
4169                return Err(MigrateError::UnsafeAlter {
4170                    model: model.to_string(),
4171                    column: (*name).to_string(),
4172                    reason: "adding UNIQUE to an existing column requires a duplicate pre-check/backfill migration; otherwise existing duplicate values abort the migration".to_string(),
4173                });
4174            }
4175            // Any schema-meaningful field change triggers AlterColumn.
4176            // UI-only flags (`noform`, `noedit`, `max_length`,
4177            // `is_string_repr`, `is_multichoice`) are intentionally
4178            // excluded — they affect admin / OpenAPI rendering but
4179            // not the database schema, so emitting an ALTER would do
4180            // no DB work. The single-column `index` flag is ALSO excluded
4181            // here: an index add/remove is not a column rewrite. Folding it
4182            // into `AlterColumn` created no index on Postgres (its native
4183            // ALTER handles TYPE/nullable/UNIQUE/DEFAULT/FK/CHECK but not
4184            // indexes) and forced a full table-recreation dance on SQLite.
4185            // `diff_indexes` now emits a proper `AddIndex`/`DropIndex` for it,
4186            // which is correct and cheap on both backends.
4187            if type_changed
4188                || prev_col.nullable != curr_col.nullable
4189                || prev_col.fk_target != curr_col.fk_target
4190                || prev_col.unique != curr_col.unique
4191                || prev_col.default != curr_col.default
4192                || prev_col.choices != curr_col.choices
4193                || prev_col.choice_labels != curr_col.choice_labels
4194                || prev_col.on_delete != curr_col.on_delete
4195                || prev_col.on_update != curr_col.on_update
4196            {
4197                alter_columns.push(*name);
4198            }
4199        }
4200    }
4201
4202    let mut ops: Vec<Operation> = Vec::new();
4203
4204    // AlterColumn ops first, in name order. One AlterColumn per
4205    // changed column; each carries the full new schema so the render
4206    // can rebuild without further context. Multiple nullable flips on
4207    // one table generate multiple AlterColumns; the apply loop runs
4208    // them sequentially (each is a table-recreation, so back-to-back
4209    // alters drop and recreate twice; the cost is acceptable while
4210    // M5.1 ships the simple case).
4211    //
4212    // audit_2 H21: the SQLite recreation dance rebuilds the table by
4213    // `INSERT INTO tmp (new_columns) SELECT new_columns FROM <old>`, so
4214    // every name in `new_columns` MUST exist in the old table at alter
4215    // time. Using `current.fields` here broke any diff that combined an
4216    // alter with an add or drop on the same table: a newly-ADDED column
4217    // is in `current` but not the old table (the SELECT hits "no such
4218    // column"), and a DROPPED column is absent from `current` so the
4219    // rebuild removed it early — the subsequent `DropColumn` op then
4220    // failed on the already-gone column. Instead, shape `new_columns`
4221    // like the PREVIOUS table (exactly the old table's columns), but
4222    // apply the CURRENT definition to every column that survives so the
4223    // type/nullable/default change still lands. A to-be-dropped column
4224    // keeps its old definition and rides through the rebuild; its
4225    // `DropColumn` op (emitted below, so it runs after) removes it. A
4226    // to-be-added column is intentionally absent; its `AddColumn` op
4227    // (also below) adds it after. Ordering alter → drop → add is what
4228    // the existing op emission already does.
4229    let new_columns: Vec<Column> = previous
4230        .fields
4231        .iter()
4232        .map(|prev_col| {
4233            curr_cols
4234                .get(prev_col.name.as_str())
4235                .map(|c| (*c).clone())
4236                .unwrap_or_else(|| prev_col.clone())
4237        })
4238        .collect();
4239    let prev_columns_snapshot: Vec<Column> = previous.fields.clone();
4240    for name in alter_columns {
4241        ops.push(Operation::AlterColumn {
4242            table: current.table.clone(),
4243            column: name.to_string(),
4244            new_columns: new_columns.clone(),
4245            prev_columns: Some(prev_columns_snapshot.clone()),
4246            // audit_2 core-migrate #10: carry the table-level constraints so the
4247            // SQLite recreation dance re-creates them instead of dropping them.
4248            unique_together: current.unique_together.clone(),
4249            indexes: current.indexes.clone(),
4250        });
4251    }
4252
4253    // Collect the dropped + added column names. We need both lists in
4254    // memory so the rename heuristic can pair them.
4255    let mut dropped: Vec<&Column> = Vec::new();
4256    let mut added: Vec<&Column> = Vec::new();
4257    for (name, prev_col) in &prev_cols {
4258        if !curr_cols.contains_key(name) {
4259            dropped.push(prev_col);
4260        }
4261    }
4262    for col in &current.fields {
4263        if !prev_cols.contains_key(col.name.as_str()) {
4264            added.push(col);
4265        }
4266    }
4267
4268    // Gap 88 — column rename detection. When the same diff yields
4269    // exactly one drop and one add whose column shapes (sans name)
4270    // match bit-for-bit, the most likely interpretation is a rename
4271    // rather than a coincidental drop+add of two unrelated columns.
4272    // Emit RenameColumn instead and warn the user so they can
4273    // verify. Anything more ambiguous (multiple drops or adds, or
4274    // mismatched shapes) falls back to the drop+add path so the
4275    // rename is never inferred against the user's actual intent.
4276    //
4277    // The heuristic deliberately stays conservative: some tools ask
4278    // interactively in this case; we don't have
4279    // a prompt at v1, so the conservative auto-pair is the safest
4280    // shape. Users can always override by writing the
4281    // `RenameColumn` op into the migration file by hand.
4282    let mut paired_drop: Option<&str> = None;
4283    let mut paired_add: Option<&str> = None;
4284    if dropped.len() == 1 && added.len() == 1 {
4285        let d = dropped[0];
4286        let a = added[0];
4287        if column_shape_matches(d, a) {
4288            eprintln!(
4289                "umbral makemigrations: column rename detected on `{}`: \
4290                 `{}` → `{}` — verify this is a rename and not a coincidental \
4291                 shape match; edit the migration file if it's wrong",
4292                current.table, d.name, a.name,
4293            );
4294            ops.push(Operation::RenameColumn {
4295                table: current.table.clone(),
4296                from: d.name.clone(),
4297                to: a.name.clone(),
4298                column: Some(a.clone()),
4299            });
4300            paired_drop = Some(d.name.as_str());
4301            paired_add = Some(a.name.as_str());
4302        }
4303    }
4304
4305    // Drops first so a same-position add can reuse the column slot.
4306    for col in &dropped {
4307        if Some(col.name.as_str()) == paired_drop {
4308            continue;
4309        }
4310        ops.push(Operation::DropColumn {
4311            table: current.table.clone(),
4312            column: col.name.clone(),
4313        });
4314    }
4315
4316    // Then adds, in current declaration order so the schema retains
4317    // the user-written column order even after re-runs.
4318    for col in &added {
4319        if Some(col.name.as_str()) == paired_add {
4320            continue;
4321        }
4322        // Gap 97 — refuse to add a NOT NULL column without a default
4323        // (and without `auto_now_add` / `auto_now`, which fill the
4324        // column server-side at insert). SQLite + Postgres both
4325        // reject the ADD on a non-empty table; we surface the same
4326        // failure at diff time with actionable guidance so the user
4327        // doesn't ship a migration that bricks every deploy.
4328        if !col.nullable
4329            && col.default.is_empty()
4330            && !col.auto_now_add
4331            && !col.auto_now
4332            && !col.primary_key
4333        {
4334            return Err(MigrateError::UnsafeAlter {
4335                model: model.to_string(),
4336                column: col.name.clone(),
4337                reason: format!(
4338                    "adding NOT NULL column `{}` without a default to existing \
4339                     table `{}` would fail on every populated row. Pick one: \
4340                     (a) make the field `Option<T>`, (b) add `#[umbral(default = \
4341                     \"...\")]` so the migration backfills, or (c) add \
4342                     `#[umbral(auto_now_add)]` for timestamp columns",
4343                    col.name, current.table,
4344                ),
4345            });
4346        }
4347        ops.push(Operation::AddColumn {
4348            table: current.table.clone(),
4349            column: (*col).clone(),
4350        });
4351    }
4352
4353    Ok(ops)
4354}
4355
4356/// Gap 88 helper: compare two column snapshots for shape identity (every
4357/// schema-meaningful attribute except `name`). Used by the rename-
4358/// detection heuristic — bit-identical attrs are the signal that a
4359/// dropped column matches an added column and the diff is actually a
4360/// rename. Excludes UI-only flags (`noform`, `noedit`, `max_length`,
4361/// `is_string_repr`, `help`, `example`, `slug_from`) for the same
4362/// reason the AlterColumn diff excludes them: they have no DB effect.
4363fn column_shape_matches(a: &Column, b: &Column) -> bool {
4364    a.ty == b.ty
4365        && a.primary_key == b.primary_key
4366        && a.nullable == b.nullable
4367        && a.fk_target == b.fk_target
4368        && a.choices == b.choices
4369        && a.choice_labels == b.choice_labels
4370        && a.default == b.default
4371        && a.is_multichoice == b.is_multichoice
4372        && a.unique == b.unique
4373        && a.on_delete == b.on_delete
4374        && a.on_update == b.on_update
4375        && a.index == b.index
4376        && a.auto_now_add == b.auto_now_add
4377        && a.auto_now == b.auto_now
4378        && a.min == b.min
4379        && a.max == b.max
4380        && a.text_format == b.text_format
4381}
4382
4383/// Pick the suffix used in a migration filename. Single-op migrations
4384/// get a descriptive suffix; multi-op migrations fall back to `auto`.
4385fn suffix_for(ops: &[Operation]) -> String {
4386    match ops {
4387        [Operation::CreateTable { table, .. }] => format!("create_{table}"),
4388        [Operation::DropTable { table }] => format!("drop_{table}"),
4389        [Operation::AddColumn { table, column }] => format!("add_{}_{}", table, column.name),
4390        [Operation::DropColumn { table, column }] => format!("drop_{table}_{column}"),
4391        [Operation::AlterColumn { table, column, .. }] => format!("alter_{table}_{column}"),
4392        [Operation::RenameTable { from, to }] => format!("rename_{from}_to_{to}"),
4393        [
4394            Operation::RenameColumn {
4395                table, from, to, ..
4396            },
4397        ] => format!("rename_{table}_{from}_to_{to}"),
4398        [Operation::RunSql { .. }] => "run_sql".to_string(),
4399        [Operation::AddIndex { table, columns, .. }] => {
4400            format!("add_index_{table}_{}", columns.join("_"))
4401        }
4402        [Operation::DropIndex { table, columns, .. }] => {
4403            format!("drop_index_{table}_{}", columns.join("_"))
4404        }
4405        _ => "auto".to_string(),
4406    }
4407}
4408
4409/// Create the tracking table if it isn't there already. The DDL is
4410/// dialect-neutral (TEXT + composite PK is valid SQL on both shipped
4411/// backends), but the executor type isn't — sqlx::query is generic
4412/// over the database, so each backend gets its own thin wrapper.
4413///
4414/// Kept inline because this table is a chicken-and-egg case: every
4415/// other migration needs the tracking row written, so the table
4416/// itself can't be a migration.
4417async fn ensure_tracking_table_sqlite(pool: &sqlx::SqlitePool) -> Result<(), MigrateError> {
4418    sqlx::query(
4419        "CREATE TABLE IF NOT EXISTS umbral_migrations (
4420            plugin TEXT NOT NULL,
4421            name TEXT NOT NULL,
4422            applied_at TEXT NOT NULL,
4423            snapshot_hash TEXT NOT NULL,
4424            PRIMARY KEY (plugin, name)
4425        )",
4426    )
4427    .execute(pool)
4428    .await?;
4429    Ok(())
4430}
4431
4432/// Postgres counterpart to [`ensure_tracking_table_sqlite`].
4433async fn ensure_tracking_table_postgres(pool: &sqlx::PgPool) -> Result<(), MigrateError> {
4434    sqlx::query(
4435        "CREATE TABLE IF NOT EXISTS umbral_migrations (
4436            plugin TEXT NOT NULL,
4437            name TEXT NOT NULL,
4438            applied_at TEXT NOT NULL,
4439            snapshot_hash TEXT NOT NULL,
4440            PRIMARY KEY (plugin, name)
4441        )",
4442    )
4443    .execute(pool)
4444    .await?;
4445    Ok(())
4446}
4447
4448/// Pull the set of `(plugin, name)` tuples already recorded in the
4449/// tracking table (SQLite).
4450async fn applied_names_sqlite(
4451    pool: &sqlx::SqlitePool,
4452) -> Result<std::collections::HashSet<(String, String)>, MigrateError> {
4453    let rows: Vec<(String, String)> = sqlx::query_as("SELECT plugin, name FROM umbral_migrations")
4454        .fetch_all(pool)
4455        .await?;
4456    Ok(rows.into_iter().collect())
4457}
4458
4459/// Postgres counterpart to [`applied_names_sqlite`].
4460async fn applied_names_postgres(
4461    pool: &sqlx::PgPool,
4462) -> Result<std::collections::HashSet<(String, String)>, MigrateError> {
4463    let rows: Vec<(String, String)> = sqlx::query_as("SELECT plugin, name FROM umbral_migrations")
4464        .fetch_all(pool)
4465        .await?;
4466    Ok(rows.into_iter().collect())
4467}
4468
4469/// Render one operation to a list of SQL statements via sea-query.
4470///
4471/// Dispatches on the ambient backend's [`crate::backend::active`]
4472/// name; SQLite and Postgres are the two shipped dialects. Most ops
4473/// produce one statement; `AlterColumn` produces either the SQLite
4474/// table-recreation dance (`CREATE _umbral_new` + `INSERT ... SELECT`
4475/// + `DROP` + `RENAME`) or a single native `ALTER TABLE ... ALTER
4476/// COLUMN ... SET/DROP NOT NULL` on Postgres.
4477///
4478/// The apply loop in `run_in` executes each statement in order inside
4479/// the same transaction.
4480///
4481/// `AddColumn` ignores the `primary_key` flag: neither SQLite nor
4482/// Postgres lets a primary key be added to an existing table without
4483/// a table-recreation step, and the autodetector won't route a
4484/// pk-flagged column through `AddColumn` anyway. A hand-edited
4485/// migration that sets the flag is taken to mean "the user is taking
4486/// responsibility".
4487fn render_operation(op: &Operation) -> Vec<String> {
4488    render_operation_for(op, crate::backend::active().name())
4489}
4490
4491fn should_emit_btree_index(col: &Column) -> bool {
4492    !col.primary_key
4493        && !col.unique
4494        && (col.index || matches!(col.ty, SqlType::ForeignKey) || col.name == "deleted_at")
4495}
4496
4497/// Render one operation against an explicit backend name. The
4498/// dispatching seam — the public [`render_operation`] is just
4499/// `render_operation_for(op, backend::active().name())`. Splitting
4500/// the two lets tests render Postgres DDL without installing the
4501/// process-wide ambient backend (the `OnceLock` can only be set once,
4502/// so `App::build` and tests would otherwise collide).
4503///
4504/// Panics on unknown backend names; only `"sqlite"` and `"postgres"`
4505/// are shipped in Phase 2.
4506pub fn render_operation_for(op: &Operation, backend_name: &str) -> Vec<String> {
4507    match backend_name {
4508        "sqlite" => render_operation_sqlite(op),
4509        "postgres" => render_operation_postgres(op),
4510        other => panic!(
4511            "umbral::migrate: no DDL renderer for backend `{other}`; \
4512             Phase 2 ships sqlite and postgres only"
4513        ),
4514    }
4515}
4516
4517/// SQLite-dialect rendering for one operation.
4518fn render_operation_sqlite(op: &Operation) -> Vec<String> {
4519    use sea_query::{Alias, SqliteQueryBuilder, Table};
4520
4521    match op {
4522        Operation::CreateTable {
4523            table,
4524            columns,
4525            unique_together,
4526            indexes,
4527        } => {
4528            // sea-query's TableCreateStatement renders columns inline.
4529            let mut stmt = Table::create();
4530            stmt.table(Alias::new(table));
4531            for col in columns {
4532                let mut def = build_column_def_sqlite(col);
4533                stmt.col(&mut def);
4534            }
4535            let mut stmts = vec![stmt.build(SqliteQueryBuilder)];
4536            // `unique_together` groups render as follow-up
4537            // `CREATE UNIQUE INDEX` statements rather than inline table
4538            // constraints. A named unique index enforces the SAME
4539            // constraint, but — unlike an inline `UNIQUE(...)` (which becomes
4540            // an un-droppable auto-index on SQLite / an implicit constraint
4541            // on Postgres) — it can be added and DROPPED by name after the
4542            // table exists. That is what makes `unique_together`
4543            // autodetection reversible: the exact statement an `AddIndex`
4544            // would emit, so a later `DropIndex` names the same index.
4545            for group in unique_together {
4546                stmts.push(add_index_stmt(table, group, true));
4547            }
4548            // Single-column explicit indexes plus ORM-required helper
4549            // indexes follow the CREATE TABLE. FK columns need indexes
4550            // for reverse/select-related queries, and soft-delete
4551            // models read through `deleted_at IS NULL` by default.
4552            for col in columns {
4553                if should_emit_btree_index(col) {
4554                    stmts.push(create_index_stmt(table, &col.name));
4555                }
4556            }
4557            // BUG-7: multi-column indexes follow as plain CREATE INDEX.
4558            for group in indexes {
4559                stmts.push(create_multi_index_stmt(table, group));
4560            }
4561            stmts
4562        }
4563        Operation::DropTable { table } => vec![
4564            Table::drop()
4565                .table(Alias::new(table))
4566                .build(SqliteQueryBuilder),
4567        ],
4568        Operation::AddColumn { table, column } => {
4569            // SQLite-specific limitation: `ALTER TABLE ADD COLUMN`
4570            // requires a CONSTANT default. `CURRENT_TIMESTAMP` is
4571            // non-constant ("Cannot add a column with non-constant
4572            // default"). So when we're adding a NOT NULL auto_now /
4573            // auto_now_add column on top of an existing table, we
4574            // emit a two-statement sequence:
4575            //   1. ADD COLUMN as NULLABLE (no default needed).
4576            //   2. UPDATE every existing row to `datetime('now')`.
4577            // The column ends up NULL-permitting at the DB level on
4578            // SQLite — but the Rust type stays `DateTime<Utc>` (not
4579            // Option), and every INSERT through the ORM supplies a
4580            // value via the macro-emitted auto_now arm. The DB-side
4581            // NOT NULL guarantee is lost only for direct-SQL writers,
4582            // which umbral already discourages (see CLAUDE.md "Plugins
4583            // use the ORM"). Postgres has no such restriction —
4584            // `DEFAULT now()` works there in ALTER, no backfill
4585            // statement needed (see the Postgres render below).
4586            let needs_backfill = (column.auto_now || column.auto_now_add)
4587                && !column.nullable
4588                && matches!(
4589                    column.ty,
4590                    SqlType::Timestamptz | SqlType::Date | SqlType::Time
4591                );
4592
4593            let mut stmts = if needs_backfill {
4594                let mut nullable_col = column.clone();
4595                nullable_col.nullable = true;
4596                let mut stmt = Table::alter();
4597                stmt.table(Alias::new(table));
4598                let mut def = build_column_def_sqlite(&nullable_col);
4599                stmt.add_column(&mut def);
4600                let add_sql = stmt.build(SqliteQueryBuilder);
4601
4602                // Manual UPDATE — sea-query's update builder is
4603                // overkill for a single SET col = datetime('now').
4604                let table_quoted = table.replace('"', "\"\"");
4605                let col_quoted = column.name.replace('"', "\"\"");
4606                let backfill_sql = format!(
4607                    "UPDATE \"{table_quoted}\" SET \"{col_quoted}\" = datetime('now') \
4608                     WHERE \"{col_quoted}\" IS NULL"
4609                );
4610                vec![add_sql, backfill_sql]
4611            } else {
4612                let mut stmt = Table::alter();
4613                stmt.table(Alias::new(table));
4614                let mut def = build_column_def_sqlite(column);
4615                stmt.add_column(&mut def);
4616                vec![stmt.build(SqliteQueryBuilder)]
4617            };
4618            if should_emit_btree_index(column) {
4619                stmts.push(create_index_stmt(table, &column.name));
4620            }
4621            stmts
4622        }
4623        Operation::DropColumn { table, column } => vec![
4624            Table::alter()
4625                .table(Alias::new(table))
4626                .drop_column(Alias::new(column))
4627                .build(SqliteQueryBuilder),
4628        ],
4629        Operation::AlterColumn {
4630            table,
4631            column,
4632            new_columns,
4633            prev_columns,
4634            unique_together,
4635            indexes,
4636        } => {
4637            // gaps3 #24: choices are Rust-enforced on SQLite (build_column_def_sqlite
4638            // emits no CHECK), so an alter that ONLY changes this column's
4639            // choices/labels is invisible to the schema. Skip the otherwise
4640            // byte-identical table-recreation dance. Postgres still swaps its
4641            // CHECK constraint via the Postgres renderer, so the op is still
4642            // emitted and recorded — only the SQLite render short-circuits.
4643            if prev_columns
4644                .as_ref()
4645                .is_some_and(|prev| alter_is_choices_only(column, prev, new_columns))
4646            {
4647                Vec::new()
4648            } else {
4649                render_alter_column_dance_sqlite(table, new_columns, unique_together, indexes)
4650            }
4651        }
4652        Operation::CreateM2MTable {
4653            junction_table,
4654            parent_table,
4655            parent_col,
4656            child_table,
4657            child_col,
4658            parent_ty,
4659            child_ty,
4660        } => {
4661            // Junction table for many-to-many: two FK columns + composite PK.
4662            // Column types follow the referenced PKs — `BigInt` → `INTEGER`
4663            // (SQLite affinity), `Text` → `TEXT`, `Uuid` → `TEXT` on SQLite
4664            // / `UUID` on Postgres. Raw DDL is the simplest expression of
4665            // the composite-PK + per-side cascade FK shape; sea-query's
4666            // builder can't express it cleanly in one call.
4667            vec![format!(
4668                r#"CREATE TABLE "{jt}" (
4669    "parent_id" {pty} NOT NULL REFERENCES "{pt}"("{pc}") ON DELETE CASCADE,
4670    "child_id" {cty} NOT NULL REFERENCES "{ct}"("{cc}") ON DELETE CASCADE,
4671    PRIMARY KEY ("parent_id", "child_id")
4672)"#,
4673                jt = junction_table.replace('"', "\"\""),
4674                pt = parent_table.replace('"', "\"\""),
4675                pc = parent_col.replace('"', "\"\""),
4676                ct = child_table.replace('"', "\"\""),
4677                cc = child_col.replace('"', "\"\""),
4678                pty = m2m_pk_sql_type_sqlite(*parent_ty),
4679                cty = m2m_pk_sql_type_sqlite(*child_ty),
4680            )]
4681        }
4682        Operation::DropM2MTable { junction_table } => vec![
4683            Table::drop()
4684                .table(Alias::new(junction_table))
4685                .build(SqliteQueryBuilder),
4686        ],
4687        Operation::RenameTable { from, to } => {
4688            use sea_query::{Alias, SqliteQueryBuilder, Table};
4689            vec![
4690                Table::rename()
4691                    .table(Alias::new(from.as_str()), Alias::new(to.as_str()))
4692                    .build(SqliteQueryBuilder),
4693            ]
4694        }
4695        Operation::RenameColumn {
4696            table, from, to, ..
4697        } => {
4698            // SQLite 3.25+ supports `ALTER TABLE ... RENAME COLUMN`
4699            // natively. Quote both sides to allow names that need
4700            // escaping; sea-query's column-rename builder isn't
4701            // exposed cleanly so we render the DDL string directly.
4702            let t = table.replace('"', "\"\"");
4703            let f = from.replace('"', "\"\"");
4704            let tn = to.replace('"', "\"\"");
4705            vec![format!(
4706                "ALTER TABLE \"{t}\" RENAME COLUMN \"{f}\" TO \"{tn}\""
4707            )]
4708        }
4709        // A data migration renders to its raw forward SQL verbatim —
4710        // the author owns portability across backends.
4711        Operation::RunSql { sql, .. } => vec![sql.clone()],
4712        // Composite index / UNIQUE constraint add + drop. The
4713        // `CREATE [UNIQUE] INDEX IF NOT EXISTS` / `DROP INDEX IF EXISTS`
4714        // forms are identical on SQLite and Postgres, so both render arms
4715        // share the same helpers.
4716        Operation::AddIndex {
4717            table,
4718            columns,
4719            unique,
4720        } => vec![add_index_stmt(table, columns, *unique)],
4721        Operation::DropIndex {
4722            table,
4723            columns,
4724            unique,
4725        } => vec![drop_index_stmt(&index_name(table, columns, *unique))],
4726    }
4727}
4728
4729/// Postgres-dialect rendering for one operation.
4730///
4731/// Postgres has native `ALTER COLUMN` so `AlterColumn` doesn't need
4732/// the SQLite table-recreation dance; it lowers to a single statement.
4733/// Integer primary keys use sea-query's `auto_increment()` flag, which
4734/// the Postgres query builder lowers to `BIGSERIAL` / `SERIAL` rather
4735/// than SQLite's `INTEGER PRIMARY KEY AUTOINCREMENT` quirk.
4736fn render_operation_postgres(op: &Operation) -> Vec<String> {
4737    use sea_query::{Alias, PostgresQueryBuilder, Table};
4738
4739    match op {
4740        Operation::CreateTable {
4741            table,
4742            columns,
4743            unique_together,
4744            indexes,
4745        } => {
4746            let mut stmt = Table::create();
4747            stmt.table(Alias::new(table));
4748            for col in columns {
4749                let mut def = build_column_def_postgres(col);
4750                stmt.col(&mut def);
4751            }
4752            let mut stmts = vec![stmt.build(PostgresQueryBuilder)];
4753            // gaps3 #35: a `#[umbral(case_insensitive)]` text column renders as
4754            // `citext`, which needs its extension. Emit the (idempotent) create
4755            // BEFORE the table so the citext type resolves. Requires a role with
4756            // CREATE privilege on the database — the operator pre-creates it once
4757            // if the runtime role is restricted.
4758            if columns
4759                .iter()
4760                .any(|c| c.case_insensitive && matches!(c.ty, crate::orm::SqlType::Text))
4761            {
4762                stmts.insert(0, "CREATE EXTENSION IF NOT EXISTS citext".to_string());
4763            }
4764            // `unique_together` as follow-up `CREATE UNIQUE INDEX` (not an
4765            // inline constraint) so it is droppable by name — see the SQLite
4766            // render arm for the full rationale.
4767            for group in unique_together {
4768                stmts.push(add_index_stmt(table, group, true));
4769            }
4770            for col in columns {
4771                if matches!(col.ty, crate::orm::SqlType::FullText) {
4772                    // tsvector columns get an auto-GIN index (#33) — they're
4773                    // useless for search without one, so the engine never
4774                    // makes the caller hand-write it.
4775                    stmts.push(create_gin_index_stmt(table, &col.name));
4776                } else if should_emit_btree_index(col) {
4777                    stmts.push(create_index_stmt(table, &col.name));
4778                }
4779            }
4780            for group in indexes {
4781                stmts.push(create_multi_index_stmt(table, group));
4782            }
4783            stmts
4784        }
4785        Operation::DropTable { table } => vec![
4786            Table::drop()
4787                .table(Alias::new(table))
4788                .build(PostgresQueryBuilder),
4789        ],
4790        Operation::AddColumn { table, column } => {
4791            let mut stmt = Table::alter();
4792            stmt.table(Alias::new(table));
4793            let mut def = build_column_def_postgres(column);
4794            stmt.add_column(&mut def);
4795            let mut stmts = vec![stmt.build(PostgresQueryBuilder)];
4796            if matches!(column.ty, crate::orm::SqlType::FullText) {
4797                // Auto-GIN for a tsvector column added later (#33).
4798                stmts.push(create_gin_index_stmt(table, &column.name));
4799            } else if should_emit_btree_index(column) {
4800                stmts.push(create_index_stmt(table, &column.name));
4801            }
4802            stmts
4803        }
4804        Operation::DropColumn { table, column } => vec![
4805            Table::alter()
4806                .table(Alias::new(table))
4807                .drop_column(Alias::new(column))
4808                .build(PostgresQueryBuilder),
4809        ],
4810        Operation::AlterColumn {
4811            table,
4812            column,
4813            new_columns,
4814            prev_columns,
4815            // Postgres alters in place — indexes/UNIQUE survive the ALTER, so
4816            // it doesn't re-create them (only the SQLite recreation dance does).
4817            unique_together: _,
4818            indexes: _,
4819        } => render_alter_column_postgres(table, column, new_columns, prev_columns.as_deref()),
4820        Operation::CreateM2MTable {
4821            junction_table,
4822            parent_table,
4823            parent_col,
4824            child_table,
4825            child_col,
4826            parent_ty,
4827            child_ty,
4828        } => {
4829            vec![format!(
4830                r#"CREATE TABLE "{jt}" (
4831    "parent_id" {pty} NOT NULL REFERENCES "{pt}"("{pc}") ON DELETE CASCADE,
4832    "child_id" {cty} NOT NULL REFERENCES "{ct}"("{cc}") ON DELETE CASCADE,
4833    PRIMARY KEY ("parent_id", "child_id")
4834)"#,
4835                jt = junction_table.replace('"', "\"\""),
4836                pt = parent_table.replace('"', "\"\""),
4837                pc = parent_col.replace('"', "\"\""),
4838                ct = child_table.replace('"', "\"\""),
4839                cc = child_col.replace('"', "\"\""),
4840                pty = m2m_pk_sql_type_postgres(*parent_ty),
4841                cty = m2m_pk_sql_type_postgres(*child_ty),
4842            )]
4843        }
4844        Operation::DropM2MTable { junction_table } => vec![
4845            Table::drop()
4846                .table(Alias::new(junction_table))
4847                .build(PostgresQueryBuilder),
4848        ],
4849        Operation::RenameTable { from, to } => {
4850            // Postgres: ALTER TABLE "<from>" RENAME TO "<to>"
4851            // sea-query's Table::rename() emits the right form.
4852            use sea_query::{Alias, PostgresQueryBuilder, Table};
4853            vec![
4854                Table::rename()
4855                    .table(Alias::new(from.as_str()), Alias::new(to.as_str()))
4856                    .build(PostgresQueryBuilder),
4857            ]
4858        }
4859        Operation::RenameColumn {
4860            table, from, to, ..
4861        } => {
4862            let t = table.replace('"', "\"\"");
4863            let f = from.replace('"', "\"\"");
4864            let tn = to.replace('"', "\"\"");
4865            vec![format!(
4866                "ALTER TABLE \"{t}\" RENAME COLUMN \"{f}\" TO \"{tn}\""
4867            )]
4868        }
4869        // A data migration renders to its raw forward SQL verbatim —
4870        // the author owns portability across backends.
4871        Operation::RunSql { sql, .. } => vec![sql.clone()],
4872        // Same `CREATE [UNIQUE] INDEX IF NOT EXISTS` / `DROP INDEX IF EXISTS`
4873        // as SQLite — Postgres accepts the identical form.
4874        Operation::AddIndex {
4875            table,
4876            columns,
4877            unique,
4878        } => vec![add_index_stmt(table, columns, *unique)],
4879        Operation::DropIndex {
4880            table,
4881            columns,
4882            unique,
4883        } => vec![drop_index_stmt(&index_name(table, columns, *unique))],
4884    }
4885}
4886
4887/// The SQLite table-recreation dance for `AlterColumn`. SQLite has no
4888/// in-place `ALTER COLUMN`, so the only safe way to flip a column's
4889/// nullable flag is to rebuild the table:
4890///
4891/// 1. `CREATE TABLE _umbral_new_<table>` with the new schema.
4892/// 2. `INSERT ... SELECT` to copy every row from the old table.
4893/// 3. `DROP TABLE <table>`.
4894/// 4. `ALTER TABLE _umbral_new_<table> RENAME TO <table>`.
4895///
4896/// Wrapped in a transaction by the caller, which — when the migration contains
4897/// an `AlterColumn` — brackets that transaction with `PRAGMA foreign_keys=OFF`
4898/// … `PRAGMA foreign_key_check` … `PRAGMA foreign_keys=ON` (SQLite's official
4899/// recipe), so step 3's `DROP TABLE` on a table with **inbound** FKs doesn't
4900/// trip `FOREIGN KEY constraint failed` (gaps3 #13). Indexes, triggers, and FK
4901/// targets aren't preserved at M5.1 because umbral-core's schema model
4902/// doesn't yet carry them; once it does, this routine picks them up
4903/// by rebuilding them at step 1.
4904///
4905/// Nullable `TRUE -> FALSE` fails at step 2 if any row holds NULL,
4906/// which is the correct data-integrity behaviour. Nullable
4907/// `FALSE -> TRUE` always succeeds.
4908/// gaps3 #24: true when an `AlterColumn` on `column` changes ONLY the closed-set
4909/// `choices` / `choice_labels` — every schema-relevant field (type, nullable,
4910/// unique, default, FK, on_delete/update, pk, length, multichoice) is identical.
4911/// On SQLite such a delta is a no-op: choices are enforced in Rust there, not by
4912/// a DB CHECK, so rebuilding the table would produce a byte-identical schema.
4913fn alter_is_choices_only(column: &str, prev: &[Column], new: &[Column]) -> bool {
4914    let (Some(p), Some(n)) = (
4915        prev.iter().find(|c| c.name == column),
4916        new.iter().find(|c| c.name == column),
4917    ) else {
4918        return false;
4919    };
4920    let choices_changed = p.choices != n.choices || p.choice_labels != n.choice_labels;
4921    let schema_same = p.ty == n.ty
4922        && p.nullable == n.nullable
4923        && p.unique == n.unique
4924        && p.default == n.default
4925        && p.fk_target == n.fk_target
4926        && p.on_delete == n.on_delete
4927        && p.on_update == n.on_update
4928        && p.primary_key == n.primary_key
4929        && p.max_length == n.max_length
4930        && p.is_multichoice == n.is_multichoice;
4931    choices_changed && schema_same
4932}
4933
4934fn render_alter_column_dance_sqlite(
4935    table: &str,
4936    new_columns: &[Column],
4937    unique_together: &[Vec<String>],
4938    indexes: &[Vec<String>],
4939) -> Vec<String> {
4940    use sea_query::{Alias, SqliteQueryBuilder, Table};
4941
4942    let tmp = format!("_umbral_new_{table}");
4943
4944    // Step 1 — CREATE TABLE _umbral_new_<table>.
4945    let mut create = Table::create();
4946    create.table(Alias::new(&tmp));
4947    for col in new_columns {
4948        let mut def = build_column_def_sqlite(col);
4949        create.col(&mut def);
4950    }
4951
4952    // Step 2 — INSERT ... SELECT. The INSERT target list is the plain column
4953    // names; each is double-quoted so SQLite identifier rules don't bite on
4954    // reserved words. The SELECT side backfills any NOT-NULL-with-default column
4955    // via `COALESCE(col, <default>)` (audit_2 core-migrate #5) — a
4956    // nullable→NOT NULL tightening whose existing rows hold NULL would otherwise
4957    // copy NULL into the new NOT NULL column and abort the rebuild. COALESCE is
4958    // a harmless no-op for a column that never held NULLs.
4959    let insert_cols = new_columns
4960        .iter()
4961        .map(|c| format!("\"{}\"", c.name.replace('"', "\"\"")))
4962        .collect::<Vec<_>>()
4963        .join(", ");
4964    let select_exprs = new_columns
4965        .iter()
4966        .map(|c| {
4967            let name = format!("\"{}\"", c.name.replace('"', "\"\""));
4968            if !c.nullable && !c.default.is_empty() {
4969                format!("COALESCE({name}, {})", default_sql_literal(c, false))
4970            } else {
4971                name
4972            }
4973        })
4974        .collect::<Vec<_>>()
4975        .join(", ");
4976    let insert_sql =
4977        format!("INSERT INTO \"{tmp}\" ({insert_cols}) SELECT {select_exprs} FROM \"{table}\"");
4978
4979    // Step 3 — DROP TABLE <table>.
4980    let drop_sql = Table::drop()
4981        .table(Alias::new(table))
4982        .build(SqliteQueryBuilder);
4983
4984    // Step 4 — ALTER TABLE _umbral_new_<table> RENAME TO <table>.
4985    let rename_sql = Table::rename()
4986        .table(Alias::new(&tmp), Alias::new(table))
4987        .build(SqliteQueryBuilder);
4988
4989    let mut stmts = vec![
4990        create.build(SqliteQueryBuilder),
4991        insert_sql,
4992        drop_sql,
4993        rename_sql,
4994    ];
4995    // Step 5 — audit_2 core-migrate #10: re-create the secondary indexes and
4996    // composite UNIQUE constraints the dropped table carried, or the rebuild
4997    // silently drops them (duplicates become insertable — integrity loss).
4998    // Single-column / FK / soft-delete indexes are derived from the columns
4999    // (same rule as CreateTable); `unique_together` re-emits as a named
5000    // `CREATE UNIQUE INDEX` and composite `indexes` as plain `CREATE INDEX`.
5001    // All are `IF NOT EXISTS`, so the step is idempotent.
5002    for col in new_columns {
5003        if should_emit_btree_index(col) {
5004            stmts.push(create_index_stmt(table, &col.name));
5005        }
5006    }
5007    for group in unique_together {
5008        stmts.push(add_index_stmt(table, group, true));
5009    }
5010    for group in indexes {
5011        stmts.push(create_multi_index_stmt(table, group));
5012    }
5013    stmts
5014}
5015
5016/// Native Postgres `AlterColumn`. Postgres supports
5017/// `ALTER TABLE x ALTER COLUMN y SET NOT NULL` and
5018/// `ALTER TABLE x ALTER COLUMN y DROP NOT NULL` in place, so the
5019/// SQLite table-recreation dance isn't needed. Lowers to a single
5020/// statement.
5021///
5022/// `SET NOT NULL` fails at the server if any row holds NULL on `y`,
5023/// matching SQLite's INSERT-time failure on the dance — the
5024/// data-integrity contract is identical between backends.
5025///
5026/// `column` is the field name that triggered the flip; `new_columns`
5027/// is the post-change schema (carried for parity with the SQLite
5028/// dance, though Postgres only needs the one column).
5029fn render_alter_column_postgres(
5030    table: &str,
5031    column: &str,
5032    new_columns: &[Column],
5033    prev_columns: Option<&[Column]>,
5034) -> Vec<String> {
5035    let new = new_columns.iter().find(|c| c.name == column).expect(
5036        "umbral::migrate: AlterColumn op references a column missing from new_columns; \
5037             this is a bug in `diff_columns`",
5038    );
5039    let prev = prev_columns.and_then(|cols| cols.iter().find(|c| c.name == column));
5040
5041    let q_table = quote_pg_ident(table);
5042    let q_column = quote_pg_ident(column);
5043
5044    let mut stmts: Vec<String> = Vec::new();
5045
5046    // TYPE change: only when we have a previous snapshot AND it differs
5047    // AND the change is in the safe-cast whitelist (diff_columns has
5048    // already gated unsafe ones). Emitted before nullable so a NOT
5049    // NULL flip against the just-cast column reads the new type.
5050    if let Some(prev_col) = prev {
5051        if prev_col.ty != new.ty && is_safe_cast(prev_col.ty, new.ty) {
5052            let new_ty_sql = postgres_type_name(new.ty);
5053            stmts.push(format!(
5054                "ALTER TABLE {q_table} ALTER COLUMN {q_column} TYPE {new_ty_sql} USING {q_column}::{new_ty_sql}"
5055            ));
5056        }
5057    }
5058
5059    // NULL-flag change: skipped when prev is None (legacy migrations
5060    // with no snapshot — preserve the old "emit unconditionally" path
5061    // because it's idempotent on Postgres). With a snapshot, only emit
5062    // when the flag actually flipped.
5063    let nullable_changed = match prev {
5064        Some(prev_col) => prev_col.nullable != new.nullable,
5065        None => true,
5066    };
5067    if nullable_changed {
5068        // audit_2 core-migrate #5: backfill existing NULLs before tightening.
5069        // A nullable→NOT NULL flip whose column carries a default would abort on
5070        // any pre-existing NULL row (bare `SET NOT NULL` doesn't backfill, and
5071        // `SET DEFAULT` only affects future inserts). Emit the backfill UPDATE
5072        // first so the subsequent `SET NOT NULL` succeeds.
5073        if !new.nullable && !new.default.is_empty() {
5074            let lit = default_sql_literal(new, true);
5075            stmts.push(format!(
5076                "UPDATE {q_table} SET {q_column} = {lit} WHERE {q_column} IS NULL"
5077            ));
5078        }
5079        let clause = if new.nullable {
5080            "DROP NOT NULL"
5081        } else {
5082            "SET NOT NULL"
5083        };
5084        stmts.push(format!(
5085            "ALTER TABLE {q_table} ALTER COLUMN {q_column} {clause}"
5086        ));
5087    }
5088
5089    // From here down — all the gap #65 follow-up changes. Each branch
5090    // checks if `prev` exists (legacy migrations with no snapshot
5091    // skip these, matching the historical behaviour) and emits the
5092    // matching ALTER on real flips.
5093    if let Some(prev_col) = prev {
5094        // UNIQUE flag flip. Postgres autogen for column-level UNIQUE
5095        // at CREATE TABLE is `<table>_<col>_key`; we use the same
5096        // name when ADDing so a subsequent DROP finds it.
5097        if prev_col.unique != new.unique {
5098            let cname = format!("{table}_{column}_key");
5099            if new.unique {
5100                stmts.push(format!(
5101                    "ALTER TABLE {q_table} ADD CONSTRAINT \"{cname}\" UNIQUE ({q_column})"
5102                ));
5103            } else {
5104                stmts.push(format!(
5105                    "ALTER TABLE {q_table} DROP CONSTRAINT IF EXISTS \"{cname}\""
5106                ));
5107            }
5108        }
5109
5110        // DEFAULT change. Empty string in either snapshot means "no
5111        // default"; the canonical SET / DROP pair fully expresses
5112        // the transition.
5113        if prev_col.default != new.default {
5114            if new.default.is_empty() {
5115                stmts.push(format!(
5116                    "ALTER TABLE {q_table} ALTER COLUMN {q_column} DROP DEFAULT"
5117                ));
5118            } else {
5119                let escaped = new.default.replace('\'', "''");
5120                stmts.push(format!(
5121                    "ALTER TABLE {q_table} ALTER COLUMN {q_column} SET DEFAULT '{escaped}'"
5122                ));
5123            }
5124        }
5125
5126        // FK target / on_delete / on_update — these are all carried
5127        // on the same constraint, so any one of them flipping
5128        // requires a DROP + readd of the whole FK. Autogen name
5129        // convention `<table>_<col>_fkey` matches Postgres at CREATE
5130        // TABLE time. Only emitted when the new column is still a
5131        // FK; if the column stopped being a FK (ty changed away
5132        // from ForeignKey), the type-change branch above handles
5133        // it indirectly via the column type rewrite.
5134        let fk_changed = prev_col.fk_target != new.fk_target
5135            || prev_col.on_delete != new.on_delete
5136            || prev_col.on_update != new.on_update;
5137        if fk_changed && matches!(new.ty, SqlType::ForeignKey) {
5138            let cname = format!("{table}_{column}_fkey");
5139            stmts.push(format!(
5140                "ALTER TABLE {q_table} DROP CONSTRAINT IF EXISTS \"{cname}\""
5141            ));
5142            // gaps2 #22: only re-add the physical constraint when the FK
5143            // still wants one. A `db_constraint = false` FK keeps the
5144            // DROP (so flipping the flag tears down any prior constraint)
5145            // but emits no ADD CONSTRAINT.
5146            if let Some(target) = &new.fk_target
5147                && new.db_constraint
5148            {
5149                let q_target = quote_pg_ident(target);
5150                // Resolve the referenced PK column from the target model's
5151                // registered meta instead of hardcoding `"id"`. String/Uuid
5152                // PKs (e.g. `Permission.codename`) are first-class post-lift;
5153                // the CreateTable path already resolves via `fk_target_pk`
5154                // (build_column_def_postgres), so the re-add must match or it
5155                // aborts the migration ("column id does not exist") / attaches
5156                // the constraint to the wrong column.
5157                let (pk_col, _pk_ty) = fk_target_pk(&target.replace('"', "\"\""));
5158                let q_pk = quote_pg_ident(&pk_col);
5159                let on_delete_clause = new
5160                    .on_delete
5161                    .sql_keyword()
5162                    .map(|k| format!(" ON DELETE {k}"))
5163                    .unwrap_or_default();
5164                let on_update_clause = new
5165                    .on_update
5166                    .sql_keyword()
5167                    .map(|k| format!(" ON UPDATE {k}"))
5168                    .unwrap_or_default();
5169                stmts.push(format!(
5170                    "ALTER TABLE {q_table} ADD CONSTRAINT \"{cname}\" \
5171                     FOREIGN KEY ({q_column}) REFERENCES {q_target}({q_pk})\
5172                     {on_delete_clause}{on_update_clause}"
5173                ));
5174            }
5175        }
5176
5177        // CHECK constraint (single-valued choices) change. MultiChoice
5178        // uses CSV storage which can't be expressed as a column-level
5179        // IN constraint; the runtime sqlx Decode path is the guard.
5180        if prev_col.choices != new.choices && !new.is_multichoice {
5181            let cname = format!("{table}_{column}_check");
5182            stmts.push(format!(
5183                "ALTER TABLE {q_table} DROP CONSTRAINT IF EXISTS \"{cname}\""
5184            ));
5185            if !new.choices.is_empty() {
5186                let values_sql = new
5187                    .choices
5188                    .iter()
5189                    .map(|v| format!("'{}'", v.replace('\'', "''")))
5190                    .collect::<Vec<_>>()
5191                    .join(", ");
5192                stmts.push(format!(
5193                    "ALTER TABLE {q_table} ADD CONSTRAINT \"{cname}\" \
5194                     CHECK ({q_column} IN ({values_sql}))"
5195                ));
5196            }
5197        }
5198    }
5199
5200    // Defensive: if we somehow produced no statements (shouldn't
5201    // happen — diff_columns gates on at least one schema-meaningful
5202    // flag changing), fall back to a single redundant SET NULL flip
5203    // to match the legacy contract. Tests cover both branches; this
5204    // is belt-and-braces.
5205    if stmts.is_empty() {
5206        let clause = if new.nullable {
5207            "DROP NOT NULL"
5208        } else {
5209            "SET NOT NULL"
5210        };
5211        stmts.push(format!(
5212            "ALTER TABLE {q_table} ALTER COLUMN {q_column} {clause}"
5213        ));
5214    }
5215
5216    stmts
5217}
5218
5219/// Quote a SQL identifier the Postgres way: wrap in double quotes,
5220/// escape inner double quotes by doubling them. Matches sea-query's
5221/// `PostgresQueryBuilder` output for identifiers so the rendered
5222/// statements look uniform.
5223fn quote_pg_ident(ident: &str) -> String {
5224    format!("\"{}\"", ident.replace('"', "\"\""))
5225}
5226
5227/// Build a SQLite `ColumnDef`. SQLite has one important quirk: its
5228/// ROWID-alias mechanic (which gives a primary-key column auto-
5229/// increment behaviour out of the box) only fires when the column's
5230/// type is the exact text `INTEGER` — case-insensitive but no other
5231/// variant. `BIGINT PRIMARY KEY`, even on a column the M3 derive
5232/// declared as `i64`, does NOT auto-increment, so an `INSERT INTO t
5233/// (other_col) VALUES (...)` without an explicit PK value fails the
5234/// NOT NULL constraint. Every umbral user with an `id: i64` model
5235/// would hit this without the override.
5236///
5237/// The fix: when a column is a primary key with an integer SqlType
5238/// (Integer or BigInt), force the rendered type to `Integer` and
5239/// attach `auto_increment()` so the generated DDL reads `"id" integer
5240/// NOT NULL PRIMARY KEY AUTOINCREMENT`. SQLite stores both `i32` and
5241/// `i64` as INTEGER affinity anyway, so the override is a no-op
5242/// semantically — the rows that round-trip through `sqlx::FromRow`
5243/// deserialize back into `i64` cleanly.
5244///
5245/// For `SqlType::Uuid` PKs: SQLite stores UUIDs as TEXT. No
5246/// `DEFAULT gen_random_uuid()` is emitted; the application must supply
5247/// the UUID at create time (or pass `Uuid::nil()` to trigger the
5248/// omit-on-insert sentinel that leaves the column to a future default).
5249///
5250/// For `SqlType::ForeignKey` columns: rendered as `BIGINT` with a
5251/// `REFERENCES "<target>"("id")` suffix appended via `.extra()`. The
5252/// target table name comes from `col.fk_target`.
5253/// Look up the FK target model's primary-key column name and SQL
5254/// type. Walks the registered ModelMeta set to find the model whose
5255/// table matches `fk_target_table`, then picks the first column
5256/// marked `primary_key = true`. Falls back to `("id", BigInteger)`
5257/// when the target isn't registered (cross-plugin lookup miss, or
5258/// the FK points outside the framework's model registry).
5259///
5260/// Used by both the SQLite and Postgres FK column-def builders so the
5261/// generated `<col> <type> REFERENCES <tbl>(<pk_col>)` matches the
5262/// target's actual PK shape — gap #60 made non-`id`, non-i64 PKs
5263/// (e.g. `Permission.codename: String`) a real case.
5264fn fk_target_pk(fk_target_table: &str) -> (String, sea_query::ColumnType) {
5265    use sea_query::ColumnType;
5266    let unesc = fk_target_table.replace("\"\"", "\"");
5267    // Non-panicking registry read — `registered_models()` itself
5268    // panics when called outside an `App::build()` context, but the
5269    // migration engine's unit tests construct snapshots by hand and
5270    // call into DDL emit without booting the framework. Fall through
5271    // to the historical "id"/BigInteger default in that case.
5272    let Some(metas) = REGISTRY.get() else {
5273        return ("id".to_string(), ColumnType::BigInteger);
5274    };
5275    for meta in metas.iter().map(|(_, m)| m) {
5276        if meta.table != unesc {
5277            continue;
5278        }
5279        if let Some(pk) = meta.fields.iter().find(|c| c.primary_key) {
5280            // Map the PK's SqlType to a sea-query ColumnType. We can't
5281            // route through `SqliteBackend::map_column` because that
5282            // wants a `Column` and applies max_length / choices
5283            // metadata which is irrelevant to a FK column. Hand-roll
5284            // the few cases the framework supports for PKs.
5285            let ct = match pk.ty {
5286                SqlType::BigInt | SqlType::Integer => ColumnType::BigInteger,
5287                SqlType::SmallInt => ColumnType::SmallInteger,
5288                SqlType::Text => ColumnType::Text,
5289                SqlType::Uuid => ColumnType::Uuid,
5290                // Other PK types fall back to BigInteger as the
5291                // historical default. The compile-time PrimaryKey
5292                // trait keeps this list closed in practice.
5293                _ => ColumnType::BigInteger,
5294            };
5295            return (pk.name.clone(), ct);
5296        }
5297    }
5298    ("id".to_string(), ColumnType::BigInteger)
5299}
5300
5301fn build_column_def_sqlite(col: &Column) -> sea_query::ColumnDef {
5302    use sea_query::{Alias, ColumnDef, ColumnType};
5303
5304    // ForeignKey gets a special path: column type + inline REFERENCES
5305    // clause both derived from the target model's PK column.
5306    if matches!(col.ty, SqlType::ForeignKey) {
5307        let fk_target = col
5308            .fk_target
5309            .as_deref()
5310            .unwrap_or("_unknown_")
5311            .replace('"', "\"\"");
5312        let (pk_col_name, pk_col_type) = fk_target_pk(&fk_target);
5313        let mut def = ColumnDef::new_with_type(Alias::new(&col.name), pk_col_type);
5314        if !col.nullable {
5315            def.not_null();
5316        }
5317        // BUG-15: `#[umbral(unique)]` on a FK column is the
5318        // OneToOne idiom — emit UNIQUE inline so the
5319        // referencing-row uniqueness is enforced at the DB.
5320        // The FK branch used to skip this because it returned
5321        // before the non-FK unique branch ran.
5322        if col.unique {
5323            def.unique_key();
5324        }
5325        // gaps2 #22: `#[umbral(db_constraint = false)]` keeps the logical
5326        // FK (column type derived from the target PK, above) but emits
5327        // NO physical `REFERENCES` clause. This is the only valid shape
5328        // for a cross-database FK. The default (`true`) emits the
5329        // constraint as before.
5330        if col.db_constraint {
5331            def.extra(format!(
5332                "REFERENCES \"{fk_target}\"(\"{pk_col_name}\"){}",
5333                fk_action_suffix(col),
5334            ));
5335        }
5336        return def;
5337    }
5338
5339    let is_int_pk = col.primary_key && matches!(col.ty, SqlType::Integer | SqlType::BigInt);
5340
5341    let column_type = if is_int_pk {
5342        ColumnType::Integer
5343    } else {
5344        crate::backend::SqliteBackend.map_column(col)
5345    };
5346
5347    let mut def = ColumnDef::new_with_type(Alias::new(&col.name), column_type);
5348    if !col.nullable {
5349        def.not_null();
5350    }
5351    if col.primary_key {
5352        def.primary_key();
5353        if is_int_pk {
5354            def.auto_increment();
5355        }
5356    }
5357    // gaps3 #35: `#[umbral(case_insensitive)]` on SQLite lifts to a column-level
5358    // `COLLATE NOCASE`, so `=`, `UNIQUE`, and `ORDER BY` fold case while storage
5359    // keeps the original casing. Emitted before UNIQUE so the auto-created
5360    // unique index inherits the column's NOCASE collation. NOCASE folds ASCII
5361    // A–Z only (a boot check warns about Unicode); Postgres uses `citext`.
5362    if col.case_insensitive && matches!(col.ty, SqlType::Text) {
5363        def.extra("COLLATE NOCASE".to_string());
5364    }
5365    // `#[umbral(unique)]` lifts to a column-level UNIQUE clause.
5366    // Skipped on PK columns (already unique) so the DDL stays tidy.
5367    if col.unique && !col.primary_key {
5368        def.unique_key();
5369    }
5370    // IMP-3: `#[umbral(min = N)]` / `#[umbral(max = N)]` lift to a
5371    // column-level CHECK clause. Both SQLite and Postgres accept the
5372    // same syntax. The pre-validation in `insert_json`/`update_json`
5373    // catches violations earlier with a friendlier error; the CHECK
5374    // is the DB-side safety net against direct-SQL writers.
5375    if let Some(check) = check_min_max_sql(col) {
5376        def.extra(check);
5377    }
5378    // User-declared `#[umbral(default = "...")]` lifts to a DDL DEFAULT
5379    // clause. Required when emitting `ALTER TABLE ADD COLUMN` for a
5380    // NOT NULL column against a non-empty table (SQLite rejects the
5381    // ADD otherwise); on CREATE TABLE it sets the column-level default
5382    // the database uses when an INSERT omits the value.
5383    //
5384    // SQLite stores booleans as INTEGER; the literal `'true'` /
5385    // `'false'` would land as a TEXT default that fails type checks
5386    // on reads. Translate Boolean defaults to `1` / `0` so the
5387    // stored representation matches what sqlx expects on hydration
5388    // (closes IMP-2 in bugs/tests/testBugs.md).
5389    if !col.default.is_empty() {
5390        if matches!(col.ty, SqlType::Boolean) {
5391            // Pass an integer to sea-query so the rendered SQL is
5392            // `DEFAULT 1` / `DEFAULT 0` instead of the quoted-string
5393            // `DEFAULT '1'` (which sqlx rejects as TEXT on read of
5394            // a BOOLEAN column).
5395            def.default(sqlite_bool_default(&col.default));
5396        } else {
5397            def.default(col.default.clone());
5398        }
5399    }
5400    // NOTE: auto_now / auto_now_add deliberately does NOT emit a
5401    // `DEFAULT CURRENT_TIMESTAMP` here. SQLite rejects non-constant
5402    // defaults in `ALTER TABLE ADD COLUMN` ("Cannot add a column
5403    // with non-constant default") and that's the path that matters
5404    // for evolving an existing table. The SQLite `AddColumn` render
5405    // path handles the auto_now backfill via a two-statement
5406    // sequence (nullable ADD + UPDATE backfill). On CREATE TABLE
5407    // we don't need a default at all because every INSERT goes
5408    // through the macro-emitted Rust path which always supplies the
5409    // value. See `Operation::AddColumn` render below.
5410    def
5411}
5412
5413/// Render a column's `#[umbral(default = ...)]` value as a raw SQL literal for
5414/// a hand-built statement (the NOT-NULL backfill, audit_2 core-migrate #5).
5415/// sea-query quotes literals itself in the column-def path, but the backfill
5416/// `UPDATE`/`COALESCE` is a raw `format!`, so it needs the literal here.
5417/// Numeric and boolean types render unquoted (`0`, `true`); everything else is
5418/// a single-quoted string with inner quotes doubled. `is_postgres` only affects
5419/// booleans (`true`/`false` on PG, `1`/`0` on SQLite, matching each backend's
5420/// boolean storage).
5421fn default_sql_literal(col: &Column, is_postgres: bool) -> String {
5422    use crate::orm::SqlType::*;
5423    match col.ty {
5424        Boolean => {
5425            let truthy = matches!(
5426                col.default.trim().to_ascii_lowercase().as_str(),
5427                "true" | "1" | "t" | "yes"
5428            );
5429            if is_postgres {
5430                if truthy { "true" } else { "false" }.to_string()
5431            } else if truthy {
5432                "1".to_string()
5433            } else {
5434                "0".to_string()
5435            }
5436        }
5437        SmallInt | Integer | BigInt | Real | Double | Decimal | ForeignKey => {
5438            // Numeric literal — validated at derive time; emit unquoted.
5439            col.default.clone()
5440        }
5441        _ => format!("'{}'", col.default.replace('\'', "''")),
5442    }
5443}
5444
5445/// Map a user-supplied boolean default string (`"true"` / `"false"`
5446/// / `"1"` / `"0"`, case-insensitive) to the SQLite integer literal
5447/// the column expects. Anything unrecognised falls through to `0`
5448/// — a developer-visible miss (default is wrong, not stored as
5449/// text) is friendlier than the runtime decode error the textual
5450/// path produces.
5451fn sqlite_bool_default(raw: &str) -> i32 {
5452    match raw.trim().to_ascii_lowercase().as_str() {
5453        "true" | "1" | "t" | "yes" => 1,
5454        _ => 0,
5455    }
5456}
5457
5458/// IMP-3: lower `#[umbral(min = N)]` / `#[umbral(max = N)]` to a
5459/// DDL CHECK clause. Returns `None` when the column declares
5460/// neither bound. The rendered SQL works on both SQLite and
5461/// Postgres (`"<col>" >= N`, `"<col>" <= N`, joined by `AND`).
5462/// Only applied to numeric columns — applying it to text would
5463/// compare strings lexicographically and surprise everyone.
5464fn check_min_max_sql(col: &Column) -> Option<String> {
5465    if col.min.is_none() && col.max.is_none() {
5466        return None;
5467    }
5468    if !matches!(
5469        col.ty,
5470        SqlType::SmallInt | SqlType::Integer | SqlType::BigInt | SqlType::Real | SqlType::Double
5471    ) {
5472        return None;
5473    }
5474    let name = col.name.replace('"', "\"\"");
5475    let mut parts = Vec::with_capacity(2);
5476    if let Some(n) = col.min {
5477        parts.push(format!("\"{name}\" >= {n}"));
5478    }
5479    if let Some(n) = col.max {
5480        parts.push(format!("\"{name}\" <= {n}"));
5481    }
5482    Some(format!("CHECK ({})", parts.join(" AND ")))
5483}
5484
5485/// Build a Postgres `ColumnDef`. Integer primary keys use the
5486/// standard `auto_increment()` flag — sea-query's `PostgresQueryBuilder`
5487/// lowers that to `BIGSERIAL` for `BigInt` and `SERIAL` for `Integer`.
5488/// No SQLite-style INTEGER-type override needed; Postgres has proper
5489/// `BIGSERIAL` / identity columns and respects the declared width.
5490///
5491/// For `SqlType::ForeignKey` columns: rendered as `BIGINT` with a
5492/// `REFERENCES "<target>"("id")` suffix. The target table name comes
5493/// from `col.fk_target`.
5494fn build_column_def_postgres(col: &Column) -> sea_query::ColumnDef {
5495    use sea_query::{Alias, ColumnDef};
5496
5497    // ForeignKey gets a special path: column type + inline REFERENCES
5498    // clause both derived from the target model's PK.
5499    if matches!(col.ty, SqlType::ForeignKey) {
5500        let fk_target = col
5501            .fk_target
5502            .as_deref()
5503            .unwrap_or("_unknown_")
5504            .replace('"', "\"\"");
5505        let (pk_col_name, pk_col_type) = fk_target_pk(&fk_target);
5506        // sea-query's ColumnType variants are dialect-agnostic; the
5507        // same value works for both SQLite and Postgres builders here.
5508        let mut def = ColumnDef::new_with_type(Alias::new(&col.name), pk_col_type);
5509        if !col.nullable {
5510            def.not_null();
5511        }
5512        // BUG-15: `#[umbral(unique)]` on a FK column is the
5513        // OneToOne idiom — emit UNIQUE inline so the
5514        // referencing-row uniqueness is enforced at the DB.
5515        // The FK branch used to skip this because it returned
5516        // before the non-FK unique branch ran.
5517        if col.unique {
5518            def.unique_key();
5519        }
5520        // gaps2 #22: skip the physical `REFERENCES` clause when the FK
5521        // opted out of the DB constraint (cross-database FK). The
5522        // logical column + `fk_target` stay intact.
5523        if col.db_constraint {
5524            def.extra(format!(
5525                "REFERENCES \"{fk_target}\"(\"{pk_col_name}\"){}",
5526                fk_action_suffix(col),
5527            ));
5528        }
5529        return def;
5530    }
5531
5532    let column_type = crate::backend::PostgresBackend.map_column(col);
5533
5534    let mut def = ColumnDef::new_with_type(Alias::new(&col.name), column_type);
5535    if !col.nullable {
5536        def.not_null();
5537    }
5538    if col.primary_key {
5539        def.primary_key();
5540        if matches!(
5541            col.ty,
5542            SqlType::Integer | SqlType::BigInt | SqlType::SmallInt
5543        ) {
5544            def.auto_increment();
5545        }
5546    }
5547    // `#[umbral(unique)]` lifts to a column-level UNIQUE clause on
5548    // Postgres too. Skipped for PK columns (already unique).
5549    if col.unique && !col.primary_key {
5550        def.unique_key();
5551    }
5552    // IMP-3: numeric bounds CHECK. Mirrors the SQLite branch.
5553    if let Some(check) = check_min_max_sql(col) {
5554        def.extra(check);
5555    }
5556    // Single-valued Choices: emit a CHECK constraint so a third-party
5557    // process writing directly to the DB can't insert a value the Rust
5558    // enum can't model. MultiChoice carries the same choices/labels
5559    // metadata but the stored value is a CSV — a single-value `IN (...)`
5560    // constraint would reject every legal CSV. Validating "every CSV
5561    // piece is a known variant" needs a regex with per-variant
5562    // escaping, which we leave to the sqlx Decode path at v1.
5563    if !col.choices.is_empty() && !col.is_multichoice {
5564        let col_name_escaped = col.name.replace('"', "\"\"");
5565        let values_sql = col
5566            .choices
5567            .iter()
5568            .map(|v| format!("'{}'", v.replace('\'', "''")))
5569            .collect::<Vec<_>>()
5570            .join(", ");
5571        def.extra(format!("CHECK (\"{col_name_escaped}\" IN ({values_sql}))"));
5572    }
5573    // User-declared `#[umbral(default = "...")]` lifts to a DDL DEFAULT
5574    // clause. Required for `ALTER TABLE ADD COLUMN` of a NOT NULL
5575    // column against a non-empty table — Postgres needs either a
5576    // default or a separate backfill.
5577    if !col.default.is_empty() {
5578        def.default(col.default.clone());
5579    } else if (col.auto_now || col.auto_now_add)
5580        && matches!(col.ty, SqlType::Timestamptz | SqlType::Date | SqlType::Time)
5581    {
5582        // Mirror of the SQLite branch above. Without a DEFAULT
5583        // Postgres rejects `ALTER TABLE ADD COLUMN ... NOT NULL`
5584        // on a populated table. `now()` evaluates per-row during
5585        // the backfill so every existing row gets a sane value;
5586        // future INSERTs override via the macro-emitted Rust path.
5587        def.default(sea_query::Expr::cust("now()"));
5588    }
5589    def
5590}
5591
5592#[cfg(test)]
5593mod tests {
5594    use super::*;
5595    use std::collections::HashSet;
5596
5597    // ---- gaps2 #100: squash_plan (pure squash-vs-originals decision) ----
5598
5599    /// A minimal migration file for `plugin`/`id`; `replaces` lists the ids it
5600    /// squashes (all within `plugin`). Empty `replaces` = an ordinary migration.
5601    fn mf(plugin: &str, id: &str, replaces: &[&str]) -> MigrationFile {
5602        MigrationFile {
5603            id: id.to_string(),
5604            plugin: plugin.to_string(),
5605            depends_on: Vec::new(),
5606            operations: Vec::new(),
5607            snapshot_after: Snapshot::default(),
5608            replaces: replaces
5609                .iter()
5610                .map(|m| MigrationRef {
5611                    plugin: plugin.to_string(),
5612                    migration: m.to_string(),
5613                })
5614                .collect(),
5615        }
5616    }
5617
5618    fn applied_set(plugin: &str, ids: &[&str]) -> HashSet<(String, String)> {
5619        ids.iter()
5620            .map(|id| (plugin.to_string(), id.to_string()))
5621            .collect()
5622    }
5623
5624    #[test]
5625    fn squash_plan_plain_migrations_apply_when_unrecorded_skip_when_applied() {
5626        let files = vec![mf("app", "0001", &[]), mf("app", "0002", &[])];
5627        let applied = applied_set("app", &["0001"]);
5628        let plan = squash_plan(&files, &applied).unwrap();
5629        assert_eq!(plan, vec![ApplyDecision::Skip, ApplyDecision::Apply]);
5630    }
5631
5632    #[test]
5633    fn squash_plan_fresh_db_applies_squash_and_shadows_its_originals() {
5634        // Both the squash and its originals are on disk (Django keeps both).
5635        // Nothing applied → run the squash once, skip every original.
5636        let files = vec![
5637            mf("app", "0001", &[]),
5638            mf("app", "0002", &[]),
5639            mf("app", "0001_squashed_0002", &["0001", "0002"]),
5640        ];
5641        let applied = applied_set("app", &[]);
5642        let plan = squash_plan(&files, &applied).unwrap();
5643        assert_eq!(
5644            plan,
5645            vec![
5646                ApplyDecision::Skip,  // 0001 shadowed by the squash
5647                ApplyDecision::Skip,  // 0002 shadowed by the squash
5648                ApplyDecision::Apply  // the squash builds the whole schema
5649            ]
5650        );
5651    }
5652
5653    #[test]
5654    fn squash_plan_existing_db_with_full_history_record_only() {
5655        // Both originals already applied → the squash records itself without
5656        // running (its schema already exists), originals skipped.
5657        let files = vec![
5658            mf("app", "0001", &[]),
5659            mf("app", "0002", &[]),
5660            mf("app", "0001_squashed_0002", &["0001", "0002"]),
5661        ];
5662        let applied = applied_set("app", &["0001", "0002"]);
5663        let plan = squash_plan(&files, &applied).unwrap();
5664        assert_eq!(
5665            plan,
5666            vec![
5667                ApplyDecision::Skip,
5668                ApplyDecision::Skip,
5669                ApplyDecision::RecordOnly
5670            ]
5671        );
5672    }
5673
5674    #[test]
5675    fn squash_plan_already_recorded_squash_is_skipped() {
5676        // The squash ran on a previous migrate; it's in the tracking table.
5677        let files = vec![
5678            mf("app", "0001", &[]),
5679            mf("app", "0002", &[]),
5680            mf("app", "0001_squashed_0002", &["0001", "0002"]),
5681        ];
5682        let applied = applied_set("app", &["0001_squashed_0002"]);
5683        let plan = squash_plan(&files, &applied).unwrap();
5684        // originals never applied but shadowed by the (already-applied) squash.
5685        assert_eq!(
5686            plan,
5687            vec![
5688                ApplyDecision::Skip,
5689                ApplyDecision::Skip,
5690                ApplyDecision::Skip
5691            ]
5692        );
5693    }
5694
5695    #[test]
5696    fn squash_plan_partial_transition_falls_back_to_surviving_originals() {
5697        // 0001 applied, 0002 not; originals still on disk → ignore the squash,
5698        // finish 0002 individually.
5699        let files = vec![
5700            mf("app", "0001", &[]),
5701            mf("app", "0002", &[]),
5702            mf("app", "0001_squashed_0002", &["0001", "0002"]),
5703        ];
5704        let applied = applied_set("app", &["0001"]);
5705        let plan = squash_plan(&files, &applied).unwrap();
5706        assert_eq!(
5707            plan,
5708            vec![
5709                ApplyDecision::Skip,  // 0001 already applied
5710                ApplyDecision::Apply, // 0002 finishes individually
5711                ApplyDecision::Skip   // squash ignored during the transition
5712            ]
5713        );
5714    }
5715
5716    #[test]
5717    fn squash_plan_partial_transition_with_missing_original_errors() {
5718        // 0001 applied, 0002 not, and 0002's file is GONE → can't reconcile.
5719        let files = vec![
5720            mf("app", "0001", &[]),
5721            mf("app", "0001_squashed_0002", &["0001", "0002"]),
5722        ];
5723        let applied = applied_set("app", &["0001"]);
5724        let err = squash_plan(&files, &applied).unwrap_err();
5725        match err {
5726            MigrateError::SquashInconsistent {
5727                plugin,
5728                squash,
5729                missing,
5730            } => {
5731                assert_eq!(plugin, "app");
5732                assert_eq!(squash, "0001_squashed_0002");
5733                assert_eq!(missing, vec!["app/0002".to_string()]);
5734            }
5735            other => panic!("expected SquashInconsistent, got {other:?}"),
5736        }
5737    }
5738
5739    /// audit_2 core-migrate #7 — the advisory-lock key must be deterministic
5740    /// (every process computes the same key for the same target, so they
5741    /// mutually exclude) and distinct per discriminator (different aliases /
5742    /// schemas migrate concurrently). Pins the FNV constants so a refactor that
5743    /// changes the hash — silently breaking cross-process exclusion — fails.
5744    #[test]
5745    fn pg_migration_lock_key_is_deterministic_and_distinct() {
5746        // Deterministic: same input → same key, run to run, process to process.
5747        assert_eq!(
5748            pg_migration_lock_key("default"),
5749            pg_migration_lock_key("default"),
5750        );
5751        // Distinct: different aliases/schemas get different keys.
5752        assert_ne!(
5753            pg_migration_lock_key("default"),
5754            pg_migration_lock_key("replica"),
5755        );
5756        assert_ne!(
5757            pg_migration_lock_key("tenant_a"),
5758            pg_migration_lock_key("tenant_b"),
5759        );
5760        // Pin the exact value so the hash can't drift unnoticed (two binaries on
5761        // different umbral versions must still agree on the key).
5762        assert_eq!(
5763            pg_migration_lock_key("default"),
5764            {
5765                let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
5766                for b in b"umbral_migrations\0"
5767                    .iter()
5768                    .copied()
5769                    .chain(b"default".iter().copied())
5770                {
5771                    hash ^= b as u64;
5772                    hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
5773                }
5774                hash as i64
5775            },
5776            "the lock-key hash changed — this breaks cross-process exclusion \
5777             between an old and a new migrator; bump deliberately if intended",
5778        );
5779    }
5780
5781    /// M8 — `plugin_order()` falls back to `registered_plugins()` when
5782    /// no topological order has been published. The fallback keeps the
5783    /// engine usable from low-level paths that drive `init_plugins`
5784    /// directly (the M5 / M6 tests that pre-date phase 1.5 of
5785    /// `App::build()`).
5786    ///
5787    /// Runs in the lib's unit-test binary, which is wholly separate
5788    /// from the integration test binaries and so owns its own copies
5789    /// of `REGISTRY` and `PLUGIN_ORDER`. This test seeds `REGISTRY` via
5790    /// `init_plugins`, never touches `init_plugin_order`, and pins the
5791    /// fallback to the sorted-by-name `registered_plugins()` output.
5792    /// As the only test that touches either OnceLock in this binary,
5793    /// it has them to itself.
5794    #[test]
5795    fn plugin_order_falls_back_to_registered_plugins_when_unpublished() {
5796        let mut per_plugin: std::collections::HashMap<String, Vec<ModelMeta>> =
5797            std::collections::HashMap::new();
5798        per_plugin.insert(
5799            "zeta".to_string(),
5800            vec![ModelMeta {
5801                name: "ZetaModel".to_string(),
5802                table: "zeta".to_string(),
5803                fields: Vec::new(),
5804                display: "ZetaModel".to_string(),
5805                icon: "database".to_string(),
5806                database: None,
5807                singleton: false,
5808                unique_together: Vec::new(),
5809                indexes: Vec::new(),
5810                ordering: Vec::new(),
5811                m2m_relations: Vec::new(),
5812                soft_delete: false,
5813                app_label: "app".to_string(),
5814            }],
5815        );
5816        per_plugin.insert(
5817            "alpha".to_string(),
5818            vec![ModelMeta {
5819                name: "AlphaModel".to_string(),
5820                table: "alpha".to_string(),
5821                fields: Vec::new(),
5822                display: "AlphaModel".to_string(),
5823                icon: "database".to_string(),
5824                database: None,
5825                singleton: false,
5826                unique_together: Vec::new(),
5827                indexes: Vec::new(),
5828                ordering: Vec::new(),
5829                m2m_relations: Vec::new(),
5830                soft_delete: false,
5831                app_label: "app".to_string(),
5832            }],
5833        );
5834        init_plugins(per_plugin);
5835
5836        // `init_plugin_order` was never called, so `plugin_order` must
5837        // return the sorted-by-name fallback.
5838        let order = plugin_order();
5839        assert_eq!(
5840            order,
5841            vec!["alpha".to_string(), "zeta".to_string()],
5842            "fallback should sort by name; got {order:?}",
5843        );
5844        assert_eq!(
5845            order,
5846            registered_plugins(),
5847            "fallback should exactly equal registered_plugins()",
5848        );
5849    }
5850
5851    /// Gap #65: `#[umbral(unique)]` lifts to a column-level UNIQUE in
5852    /// CREATE TABLE DDL on both backends. PK columns skip the clause
5853    /// because they're already unique by virtue of being the PK.
5854    #[test]
5855    fn unique_column_emits_unique_keyword_on_both_backends() {
5856        use sea_query::{Alias, PostgresQueryBuilder, SqliteQueryBuilder, Table};
5857
5858        let id = Column {
5859            name: "id".into(),
5860            ty: SqlType::BigInt,
5861            primary_key: true,
5862            nullable: false,
5863            fk_target: None,
5864            noform: false,
5865            privileged: false,
5866            db_constraint: true,
5867            noedit: false,
5868            is_string_repr: false,
5869            max_length: 0,
5870            choices: vec![],
5871            choice_labels: vec![],
5872            default: String::new(),
5873            is_multichoice: false,
5874            // Set even though it's a PK so we can assert below that
5875            // the emit path drops the redundant clause.
5876            unique: true,
5877            on_delete: crate::orm::FkAction::NoAction,
5878            on_update: crate::orm::FkAction::NoAction,
5879            index: false,
5880            auto_now_add: false,
5881            auto_now: false,
5882            trim: false,
5883            lowercase: false,
5884            case_insensitive: false,
5885            help: String::new(),
5886            example: String::new(),
5887            widget: None,
5888            supported_backends: Vec::new(),
5889            min: None,
5890            max: None,
5891            text_format: ::core::option::Option::None,
5892            slug_from: ::core::option::Option::None,
5893        };
5894        let username = Column {
5895            name: "username".into(),
5896            ty: SqlType::Text,
5897            primary_key: false,
5898            nullable: false,
5899            fk_target: None,
5900            noform: false,
5901            privileged: false,
5902            db_constraint: true,
5903            noedit: false,
5904            is_string_repr: false,
5905            max_length: 0,
5906            choices: vec![],
5907            choice_labels: vec![],
5908            default: String::new(),
5909            is_multichoice: false,
5910            unique: true,
5911            on_delete: crate::orm::FkAction::NoAction,
5912            on_update: crate::orm::FkAction::NoAction,
5913            index: false,
5914            auto_now_add: false,
5915            auto_now: false,
5916            trim: false,
5917            lowercase: false,
5918            case_insensitive: false,
5919            help: String::new(),
5920            example: String::new(),
5921            widget: None,
5922            supported_backends: Vec::new(),
5923            min: None,
5924            max: None,
5925            text_format: ::core::option::Option::None,
5926            slug_from: ::core::option::Option::None,
5927        };
5928        let email = Column {
5929            name: "email".into(),
5930            ty: SqlType::Text,
5931            primary_key: false,
5932            nullable: false,
5933            fk_target: None,
5934            noform: false,
5935            privileged: false,
5936            db_constraint: true,
5937            noedit: false,
5938            is_string_repr: false,
5939            max_length: 0,
5940            choices: vec![],
5941            choice_labels: vec![],
5942            default: String::new(),
5943            is_multichoice: false,
5944            unique: false,
5945            on_delete: crate::orm::FkAction::NoAction,
5946            on_update: crate::orm::FkAction::NoAction,
5947            index: false,
5948            auto_now_add: false,
5949            auto_now: false,
5950            trim: false,
5951            lowercase: false,
5952            case_insensitive: false,
5953            help: String::new(),
5954            example: String::new(),
5955            widget: None,
5956            supported_backends: Vec::new(),
5957            min: None,
5958            max: None,
5959            text_format: ::core::option::Option::None,
5960            slug_from: ::core::option::Option::None,
5961        };
5962
5963        for backend in ["sqlite", "postgres"] {
5964            let mut stmt = Table::create();
5965            stmt.table(Alias::new("u"));
5966            for col in [&id, &username, &email] {
5967                let mut def = if backend == "sqlite" {
5968                    build_column_def_sqlite(col)
5969                } else {
5970                    build_column_def_postgres(col)
5971                };
5972                stmt.col(&mut def);
5973            }
5974            let sql = if backend == "sqlite" {
5975                stmt.to_string(SqliteQueryBuilder)
5976            } else {
5977                stmt.to_string(PostgresQueryBuilder)
5978            };
5979
5980            // UNIQUE on the explicitly-marked non-PK column.
5981            assert!(
5982                sql.contains("\"username\"") && sql.to_uppercase().contains("UNIQUE"),
5983                "{backend}: expected UNIQUE on username; got: {sql}",
5984            );
5985            // No UNIQUE on `email` (flag false).
5986            let email_clause = sql
5987                .split("\"email\"")
5988                .nth(1)
5989                .unwrap_or_default()
5990                .split(',')
5991                .next()
5992                .unwrap_or_default();
5993            assert!(
5994                !email_clause.to_uppercase().contains("UNIQUE"),
5995                "{backend}: email should not be UNIQUE; clause: {email_clause}",
5996            );
5997            // PK still PK; the redundant UNIQUE flag is dropped so we
5998            // don't double up the constraint.
5999            let id_clause = sql
6000                .split("\"id\"")
6001                .nth(1)
6002                .unwrap_or_default()
6003                .split(',')
6004                .next()
6005                .unwrap_or_default();
6006            assert!(
6007                id_clause.to_uppercase().contains("PRIMARY KEY"),
6008                "{backend}: id should still be PRIMARY KEY; clause: {id_clause}",
6009            );
6010            assert!(
6011                !id_clause.to_uppercase().contains("UNIQUE"),
6012                "{backend}: PK column should not also carry UNIQUE; clause: {id_clause}",
6013            );
6014        }
6015    }
6016
6017    /// Gap #68: `on_delete` / `on_update` lift to the `REFERENCES`
6018    /// tail in DDL. `NoAction` emits no clause (the SQL default);
6019    /// any other variant emits `ON DELETE <kw>` / `ON UPDATE <kw>`
6020    /// on both backends. The clause goes inside the same `extra(...)`
6021    /// string that already carries `REFERENCES "<target>"("id")` —
6022    /// the test asserts the full tail shape so a future refactor
6023    /// that splits the FK rendering won't silently regress.
6024    #[test]
6025    fn fk_action_lifts_to_references_clause_on_both_backends() {
6026        use sea_query::{Alias, PostgresQueryBuilder, SqliteQueryBuilder, Table};
6027
6028        // Need an FK target table; the DDL renderer looks up the
6029        // PK column type for `auth_user` via `fk_target_pk`.
6030        // Using "post" since it's already registered as a real
6031        // Model in the lib (resolves to BigInt id).
6032        let plain_fk = Column {
6033            name: "owner_id".into(),
6034            ty: SqlType::ForeignKey,
6035            primary_key: false,
6036            nullable: false,
6037            fk_target: Some("post".into()),
6038            noform: false,
6039            privileged: false,
6040            db_constraint: true,
6041            noedit: false,
6042            is_string_repr: false,
6043            max_length: 0,
6044            choices: vec![],
6045            choice_labels: vec![],
6046            default: String::new(),
6047            is_multichoice: false,
6048            unique: false,
6049            on_delete: crate::orm::FkAction::NoAction,
6050            on_update: crate::orm::FkAction::NoAction,
6051            index: false,
6052            auto_now_add: false,
6053            auto_now: false,
6054            trim: false,
6055            lowercase: false,
6056            case_insensitive: false,
6057            help: String::new(),
6058            example: String::new(),
6059            widget: None,
6060            supported_backends: Vec::new(),
6061            min: None,
6062            max: None,
6063            text_format: ::core::option::Option::None,
6064            slug_from: ::core::option::Option::None,
6065        };
6066        let cascade_fk = Column {
6067            on_delete: crate::orm::FkAction::Cascade,
6068            on_update: crate::orm::FkAction::Cascade,
6069            index: false,
6070            auto_now_add: false,
6071            auto_now: false,
6072            trim: false,
6073            lowercase: false,
6074            case_insensitive: false,
6075            help: String::new(),
6076            example: String::new(),
6077            widget: None,
6078            supported_backends: Vec::new(),
6079            ..plain_fk.clone()
6080        };
6081        let restrict_fk = Column {
6082            on_delete: crate::orm::FkAction::Restrict,
6083            ..plain_fk.clone()
6084        };
6085        let set_null_fk = Column {
6086            nullable: true,
6087            on_delete: crate::orm::FkAction::SetNull,
6088            ..plain_fk.clone()
6089        };
6090
6091        for backend in ["sqlite", "postgres"] {
6092            let render_one = |col: &Column| -> String {
6093                let mut stmt = Table::create();
6094                stmt.table(Alias::new("t"));
6095                let mut def = if backend == "sqlite" {
6096                    build_column_def_sqlite(col)
6097                } else {
6098                    build_column_def_postgres(col)
6099                };
6100                stmt.col(&mut def);
6101                if backend == "sqlite" {
6102                    stmt.to_string(SqliteQueryBuilder)
6103                } else {
6104                    stmt.to_string(PostgresQueryBuilder)
6105                }
6106            };
6107
6108            // NoAction → REFERENCES with no tail clauses.
6109            let sql = render_one(&plain_fk);
6110            assert!(
6111                sql.contains("REFERENCES")
6112                    && !sql.to_uppercase().contains("ON DELETE")
6113                    && !sql.to_uppercase().contains("ON UPDATE"),
6114                "{backend}: NoAction should emit REFERENCES alone; got: {sql}",
6115            );
6116
6117            // Cascade on both ON DELETE and ON UPDATE.
6118            let sql = render_one(&cascade_fk);
6119            assert!(
6120                sql.to_uppercase().contains("ON DELETE CASCADE")
6121                    && sql.to_uppercase().contains("ON UPDATE CASCADE"),
6122                "{backend}: Cascade should emit both clauses; got: {sql}",
6123            );
6124
6125            // Restrict on ON DELETE only; ON UPDATE is NoAction so
6126            // no clause appears.
6127            let sql = render_one(&restrict_fk);
6128            assert!(
6129                sql.to_uppercase().contains("ON DELETE RESTRICT"),
6130                "{backend}: Restrict missing; got: {sql}",
6131            );
6132            assert!(
6133                !sql.to_uppercase().contains("ON UPDATE"),
6134                "{backend}: ON UPDATE shouldn't appear for NoAction; got: {sql}",
6135            );
6136
6137            // SET NULL renders verbatim (two-word keyword).
6138            let sql = render_one(&set_null_fk);
6139            assert!(
6140                sql.to_uppercase().contains("ON DELETE SET NULL"),
6141                "{backend}: SET NULL missing; got: {sql}",
6142            );
6143        }
6144    }
6145
6146    /// Gap #65 follow-up: the diff engine detects changes to *every*
6147    /// schema-meaningful field, not just `ty` and `nullable`. Each
6148    /// branch builds a baseline column, mutates one field, runs
6149    /// `diff_columns`, and asserts an `AlterColumn` op is produced.
6150    /// Catches the regression where toggling `unique` or `on_delete`
6151    /// would silently leave the table unchanged.
6152    #[test]
6153    fn diff_detects_all_schema_meaningful_field_changes() {
6154        fn baseline() -> Column {
6155            Column {
6156                name: "x".into(),
6157                ty: SqlType::Text,
6158                primary_key: false,
6159                nullable: false,
6160                fk_target: None,
6161                noform: false,
6162                privileged: false,
6163                db_constraint: true,
6164                noedit: false,
6165                is_string_repr: false,
6166                max_length: 0,
6167                choices: vec![],
6168                choice_labels: vec![],
6169                default: String::new(),
6170                is_multichoice: false,
6171                unique: false,
6172                on_delete: crate::orm::FkAction::NoAction,
6173                on_update: crate::orm::FkAction::NoAction,
6174                index: false,
6175                auto_now_add: false,
6176                auto_now: false,
6177                trim: false,
6178                lowercase: false,
6179                case_insensitive: false,
6180                help: String::new(),
6181                example: String::new(),
6182                widget: None,
6183                supported_backends: Vec::new(),
6184                min: None,
6185                max: None,
6186                text_format: ::core::option::Option::None,
6187                slug_from: ::core::option::Option::None,
6188            }
6189        }
6190        fn meta_with(col: Column) -> ModelMeta {
6191            ModelMeta {
6192                name: "M".into(),
6193                table: "m".into(),
6194                fields: vec![col],
6195                display: "M".into(),
6196                icon: "database".into(),
6197                database: None,
6198                singleton: false,
6199                unique_together: Vec::new(),
6200                indexes: Vec::new(),
6201                ordering: Vec::new(),
6202                m2m_relations: Vec::new(),
6203                soft_delete: false,
6204                app_label: "app".into(),
6205            }
6206        }
6207        let prev = meta_with(baseline());
6208        // Safe-to-alter changes: each must surface as an `AlterColumn`.
6209        // (`nullable` here is false→true — a *loosening*, which is safe;
6210        // the tightening direction is guarded separately below.)
6211        let safe_mutations: Vec<(&str, fn(&mut Column))> = vec![
6212            ("default", |c| c.default = "hello".into()),
6213            ("choices", |c| {
6214                c.choices = vec!["a".into(), "b".into()];
6215                c.choice_labels = vec!["A".into(), "B".into()];
6216            }),
6217            ("nullable", |c| c.nullable = true),
6218        ];
6219        for (label, mutate) in safe_mutations {
6220            let mut col = baseline();
6221            mutate(&mut col);
6222            let current = meta_with(col);
6223            let ops = diff_columns("M", &prev, &current).expect("diff should succeed");
6224            assert!(
6225                !ops.is_empty(),
6226                "{label}: diff should produce at least one op; got none",
6227            );
6228            assert!(
6229                ops.iter()
6230                    .any(|op| matches!(op, Operation::AlterColumn { column, .. } if column == "x")),
6231                "{label}: expected AlterColumn on `x`; got: {ops:?}",
6232            );
6233        }
6234
6235        // Adding UNIQUE to an existing column is detected too, but as an
6236        // `UnsafeAlter` guard rather than a bare `AlterColumn`: dropping a
6237        // UNIQUE constraint onto a populated column aborts the migration
6238        // if duplicates already exist, so the engine refuses it with a
6239        // duplicate-pre-check message instead of silently emitting it.
6240        let mut col = baseline();
6241        col.unique = true;
6242        let current = meta_with(col);
6243        match diff_columns("M", &prev, &current) {
6244            Err(MigrateError::UnsafeAlter { column, reason, .. }) => {
6245                assert_eq!(column, "x");
6246                assert!(
6247                    reason.contains("UNIQUE"),
6248                    "unsafe-alter reason should mention UNIQUE; got: {reason}",
6249                );
6250            }
6251            other => panic!("unique add should be an UnsafeAlter guard; got: {other:?}"),
6252        }
6253    }
6254
6255    /// Gap #65 follow-up: the Postgres `AlterColumn` render handles
6256    /// the new diff types (unique, default, choices, FK actions)
6257    /// with native `ALTER TABLE ... ADD/DROP CONSTRAINT` /
6258    /// `SET/DROP DEFAULT` statements. SQLite is unchanged — the
6259    /// rebuild dance already swallows any column metadata change.
6260    #[test]
6261    fn postgres_alter_column_renders_constraint_changes() {
6262        let baseline = Column {
6263            name: "x".into(),
6264            ty: SqlType::Text,
6265            primary_key: false,
6266            nullable: false,
6267            fk_target: None,
6268            noform: false,
6269            privileged: false,
6270            db_constraint: true,
6271            noedit: false,
6272            is_string_repr: false,
6273            max_length: 0,
6274            choices: vec![],
6275            choice_labels: vec![],
6276            default: String::new(),
6277            is_multichoice: false,
6278            unique: false,
6279            on_delete: crate::orm::FkAction::NoAction,
6280            on_update: crate::orm::FkAction::NoAction,
6281            index: false,
6282            auto_now_add: false,
6283            auto_now: false,
6284            trim: false,
6285            lowercase: false,
6286            case_insensitive: false,
6287            help: String::new(),
6288            example: String::new(),
6289            widget: None,
6290            supported_backends: Vec::new(),
6291            min: None,
6292            max: None,
6293            text_format: ::core::option::Option::None,
6294            slug_from: ::core::option::Option::None,
6295        };
6296
6297        // unique false → true: emit ADD CONSTRAINT ... UNIQUE
6298        let mut new = baseline.clone();
6299        new.unique = true;
6300        let stmts = render_alter_column_postgres("m", "x", &[new], Some(&[baseline.clone()]));
6301        let joined = stmts.join("\n");
6302        assert!(
6303            joined.contains("ADD CONSTRAINT") && joined.contains("UNIQUE"),
6304            "unique add: expected ADD CONSTRAINT UNIQUE; got: {joined}",
6305        );
6306
6307        // unique true → false: emit DROP CONSTRAINT ... IF EXISTS
6308        let prev_unique = Column {
6309            unique: true,
6310            ..baseline.clone()
6311        };
6312        let stmts =
6313            render_alter_column_postgres("m", "x", &[baseline.clone()], Some(&[prev_unique]));
6314        let joined = stmts.join("\n");
6315        assert!(
6316            joined.contains("DROP CONSTRAINT IF EXISTS"),
6317            "unique drop: expected DROP CONSTRAINT IF EXISTS; got: {joined}",
6318        );
6319
6320        // default empty → "hello": SET DEFAULT 'hello'
6321        let mut new = baseline.clone();
6322        new.default = "hello".into();
6323        let stmts = render_alter_column_postgres("m", "x", &[new], Some(&[baseline.clone()]));
6324        let joined = stmts.join("\n");
6325        assert!(
6326            joined.contains("SET DEFAULT 'hello'"),
6327            "default set: expected SET DEFAULT; got: {joined}",
6328        );
6329
6330        // default "hello" → empty: DROP DEFAULT
6331        let prev_default = Column {
6332            default: "hello".into(),
6333            ..baseline.clone()
6334        };
6335        let stmts =
6336            render_alter_column_postgres("m", "x", &[baseline.clone()], Some(&[prev_default]));
6337        let joined = stmts.join("\n");
6338        assert!(
6339            joined.contains("DROP DEFAULT"),
6340            "default drop: expected DROP DEFAULT; got: {joined}",
6341        );
6342
6343        // FK on_delete change → DROP + readd FK with new clause
6344        let fk_baseline = Column {
6345            ty: SqlType::ForeignKey,
6346            fk_target: Some("other".into()),
6347            ..baseline.clone()
6348        };
6349        let fk_cascade = Column {
6350            on_delete: crate::orm::FkAction::Cascade,
6351            ..fk_baseline.clone()
6352        };
6353        let stmts = render_alter_column_postgres("m", "x", &[fk_cascade], Some(&[fk_baseline]));
6354        let joined = stmts.join("\n");
6355        assert!(
6356            joined.contains("DROP CONSTRAINT IF EXISTS")
6357                && joined.contains("FOREIGN KEY")
6358                && joined.contains("ON DELETE CASCADE"),
6359            "FK cascade add: expected drop+readd with ON DELETE CASCADE; got: {joined}",
6360        );
6361    }
6362
6363    /// IMP-2 from bugs/tests/testBugs.md: a `#[umbral(default = "true")]`
6364    /// on a boolean column used to land as `DEFAULT 'true'` on
6365    /// SQLite, which decode-fails on read (column type is INTEGER,
6366    /// the stored TEXT can't deserialize as `bool`). The SQLite
6367    /// renderer now maps the string to `1` / `0`.
6368    #[test]
6369    fn sqlite_bool_default_translates_to_integer_literal() {
6370        use sea_query::{Alias, SqliteQueryBuilder, Table};
6371
6372        let bool_col = Column {
6373            name: "is_active".into(),
6374            ty: SqlType::Boolean,
6375            primary_key: false,
6376            nullable: false,
6377            fk_target: None,
6378            noform: false,
6379            privileged: false,
6380            db_constraint: true,
6381            noedit: false,
6382            is_string_repr: false,
6383            max_length: 0,
6384            choices: vec![],
6385            choice_labels: vec![],
6386            default: "true".into(),
6387            is_multichoice: false,
6388            unique: false,
6389            on_delete: crate::orm::FkAction::NoAction,
6390            on_update: crate::orm::FkAction::NoAction,
6391            index: false,
6392            auto_now_add: false,
6393            auto_now: false,
6394            trim: false,
6395            lowercase: false,
6396            case_insensitive: false,
6397            help: String::new(),
6398            example: String::new(),
6399            widget: None,
6400            supported_backends: Vec::new(),
6401            min: None,
6402            max: None,
6403            text_format: ::core::option::Option::None,
6404            slug_from: ::core::option::Option::None,
6405        };
6406        let mut stmt = Table::create();
6407        stmt.table(Alias::new("t"));
6408        let mut def = build_column_def_sqlite(&bool_col);
6409        stmt.col(&mut def);
6410        let sql = stmt.to_string(SqliteQueryBuilder);
6411        assert!(
6412            sql.contains("DEFAULT 1") && !sql.contains("DEFAULT 'true'"),
6413            "bool default 'true' on sqlite should render as DEFAULT 1; got: {sql}",
6414        );
6415
6416        // "false" → 0
6417        let mut bool_col_false = bool_col.clone();
6418        bool_col_false.default = "false".into();
6419        let mut stmt = Table::create();
6420        stmt.table(Alias::new("t"));
6421        let mut def = build_column_def_sqlite(&bool_col_false);
6422        stmt.col(&mut def);
6423        let sql = stmt.to_string(SqliteQueryBuilder);
6424        assert!(
6425            sql.contains("DEFAULT 0") && !sql.contains("DEFAULT 'false'"),
6426            "bool default 'false' on sqlite should render as DEFAULT 0; got: {sql}",
6427        );
6428
6429        // Non-bool columns are untouched (text default stays
6430        // single-quoted literal).
6431        let text_col = Column {
6432            name: "label".into(),
6433            ty: SqlType::Text,
6434            default: "hello".into(),
6435            ..bool_col.clone()
6436        };
6437        let mut stmt = Table::create();
6438        stmt.table(Alias::new("t"));
6439        let mut def = build_column_def_sqlite(&text_col);
6440        stmt.col(&mut def);
6441        let sql = stmt.to_string(SqliteQueryBuilder);
6442        assert!(
6443            sql.contains("DEFAULT 'hello'"),
6444            "text default should stay quoted; got: {sql}",
6445        );
6446    }
6447
6448    /// BUG-4 from bugs/tests/testBugs.md: `#[umbral(index)]` lifts
6449    /// to a `CREATE INDEX IF NOT EXISTS idx_<table>_<col>` statement
6450    /// alongside the `CREATE TABLE`. The index is skipped on PK
6451    /// and UNIQUE columns (those are already indexed by the
6452    /// constraint).
6453    #[test]
6454    fn index_attribute_emits_create_index_alongside_create_table() {
6455        let id = Column {
6456            name: "id".into(),
6457            ty: SqlType::BigInt,
6458            primary_key: true,
6459            nullable: false,
6460            fk_target: None,
6461            noform: false,
6462            privileged: false,
6463            db_constraint: true,
6464            noedit: false,
6465            is_string_repr: false,
6466            max_length: 0,
6467            choices: vec![],
6468            choice_labels: vec![],
6469            default: String::new(),
6470            is_multichoice: false,
6471            unique: false,
6472            on_delete: crate::orm::FkAction::NoAction,
6473            on_update: crate::orm::FkAction::NoAction,
6474            // PK with index=true; the renderer should skip the
6475            // extra CREATE INDEX because the PK constraint
6476            // already covers it.
6477            index: true,
6478            auto_now_add: false,
6479            auto_now: false,
6480            trim: false,
6481            lowercase: false,
6482            case_insensitive: false,
6483            help: String::new(),
6484            example: String::new(),
6485            widget: None,
6486            supported_backends: Vec::new(),
6487            min: None,
6488            max: None,
6489            text_format: ::core::option::Option::None,
6490            slug_from: ::core::option::Option::None,
6491        };
6492        let slug = Column {
6493            name: "slug".into(),
6494            ty: SqlType::Text,
6495            primary_key: false,
6496            nullable: false,
6497            index: true,
6498            auto_now_add: false,
6499            auto_now: false,
6500            trim: false,
6501            lowercase: false,
6502            case_insensitive: false,
6503            help: String::new(),
6504            example: String::new(),
6505            widget: None,
6506            supported_backends: Vec::new(),
6507            ..id.clone()
6508        };
6509        let title = Column {
6510            name: "title".into(),
6511            ty: SqlType::Text,
6512            primary_key: false,
6513            nullable: false,
6514            index: false,
6515            auto_now_add: false,
6516            auto_now: false,
6517            trim: false,
6518            lowercase: false,
6519            case_insensitive: false,
6520            help: String::new(),
6521            example: String::new(),
6522            widget: None,
6523            supported_backends: Vec::new(),
6524            ..id.clone()
6525        };
6526        let op = Operation::CreateTable {
6527            table: "post".into(),
6528            columns: vec![id, slug, title],
6529            unique_together: Vec::new(),
6530            indexes: Vec::new(),
6531        };
6532
6533        for backend in ["sqlite", "postgres"] {
6534            let stmts = render_operation_for(&op, backend);
6535            assert!(
6536                stmts
6537                    .iter()
6538                    .any(|s| s.to_uppercase().contains("CREATE TABLE")),
6539                "{backend}: expected a CREATE TABLE; got: {stmts:?}",
6540            );
6541            let index_stmts: Vec<_> = stmts
6542                .iter()
6543                .filter(|s| s.to_uppercase().contains("CREATE INDEX"))
6544                .collect();
6545            assert_eq!(
6546                index_stmts.len(),
6547                1,
6548                "{backend}: expected exactly one CREATE INDEX (on `slug`); got {index_stmts:?}",
6549            );
6550            let ix = index_stmts[0];
6551            assert!(
6552                ix.contains("\"idx_post_slug\"") && ix.contains("(\"slug\")"),
6553                "{backend}: index should target post(slug); got: {ix}",
6554            );
6555            assert!(
6556                ix.to_uppercase().contains("IF NOT EXISTS"),
6557                "{backend}: should be idempotent via IF NOT EXISTS; got: {ix}",
6558            );
6559        }
6560    }
6561
6562    /// gaps3 #35: a `#[umbral(case_insensitive)]` text column renders
6563    /// per-backend — SQLite `COLLATE NOCASE`, Postgres `citext` with an
6564    /// idempotent `CREATE EXTENSION` emitted before the table.
6565    #[test]
6566    fn case_insensitive_column_renders_per_backend() {
6567        let id = Column {
6568            name: "id".into(),
6569            ty: SqlType::BigInt,
6570            primary_key: true,
6571            nullable: false,
6572            fk_target: None,
6573            noform: false,
6574            privileged: false,
6575            db_constraint: true,
6576            noedit: false,
6577            is_string_repr: false,
6578            max_length: 0,
6579            choices: vec![],
6580            choice_labels: vec![],
6581            default: String::new(),
6582            is_multichoice: false,
6583            unique: false,
6584            on_delete: crate::orm::FkAction::NoAction,
6585            on_update: crate::orm::FkAction::NoAction,
6586            index: false,
6587            auto_now_add: false,
6588            auto_now: false,
6589            trim: false,
6590            lowercase: false,
6591            case_insensitive: false,
6592            help: String::new(),
6593            example: String::new(),
6594            widget: None,
6595            supported_backends: Vec::new(),
6596            min: None,
6597            max: None,
6598            text_format: None,
6599            slug_from: None,
6600        };
6601        let name = Column {
6602            name: "name".into(),
6603            ty: SqlType::Text,
6604            primary_key: false,
6605            unique: true,
6606            case_insensitive: true,
6607            ..id.clone()
6608        };
6609        let op = Operation::CreateTable {
6610            table: "handle".into(),
6611            columns: vec![id, name],
6612            unique_together: Vec::new(),
6613            indexes: Vec::new(),
6614        };
6615
6616        // SQLite: COLLATE NOCASE on the column; no extension.
6617        let sqlite = render_operation_for(&op, "sqlite");
6618        let create = sqlite
6619            .iter()
6620            .find(|s| s.to_uppercase().contains("CREATE TABLE"))
6621            .expect("a CREATE TABLE");
6622        assert!(
6623            create.to_uppercase().contains("COLLATE NOCASE"),
6624            "sqlite case_insensitive column must carry COLLATE NOCASE; got: {create}"
6625        );
6626        assert!(
6627            !sqlite
6628                .iter()
6629                .any(|s| s.to_uppercase().contains("EXTENSION")),
6630            "sqlite must not emit a citext extension; got: {sqlite:?}"
6631        );
6632
6633        // Postgres: citext type + CREATE EXTENSION IF NOT EXISTS citext, and the
6634        // extension statement precedes the CREATE TABLE.
6635        let pg = render_operation_for(&op, "postgres");
6636        let ext_idx = pg
6637            .iter()
6638            .position(|s| s.to_uppercase().contains("CREATE EXTENSION") && s.contains("citext"))
6639            .expect("a CREATE EXTENSION citext statement");
6640        let create_idx = pg
6641            .iter()
6642            .position(|s| s.to_uppercase().contains("CREATE TABLE"))
6643            .expect("a CREATE TABLE");
6644        assert!(
6645            ext_idx < create_idx,
6646            "the citext extension must be created before the table; got: {pg:?}"
6647        );
6648        assert!(
6649            pg[create_idx].to_lowercase().contains("citext"),
6650            "postgres case_insensitive column must render as citext; got: {}",
6651            pg[create_idx]
6652        );
6653        assert!(
6654            pg[ext_idx].to_uppercase().contains("IF NOT EXISTS"),
6655            "the extension create must be idempotent; got: {}",
6656            pg[ext_idx]
6657        );
6658    }
6659
6660    /// Regression: adding an `auto_now` / `auto_now_add` column to an
6661    /// existing populated table.
6662    ///
6663    ///   - SQLite: a 2-statement sequence (nullable ADD + UPDATE
6664    ///     backfill) since SQLite refuses non-constant defaults in
6665    ///     ALTER. The column ends up nullable at the DB level;
6666    ///     Rust still enforces non-null at the type level.
6667    ///   - Postgres: a single ALTER with `DEFAULT now()` — Postgres
6668    ///     allows the non-constant default and backfills inline.
6669    #[test]
6670    fn auto_now_add_column_renders_safe_backfill_per_backend() {
6671        for (label, auto_now, auto_now_add) in
6672            [("auto_now", true, false), ("auto_now_add", false, true)]
6673        {
6674            let col = Column {
6675                name: "updated_at".to_string(),
6676                ty: SqlType::Timestamptz,
6677                primary_key: false,
6678                nullable: false,
6679                fk_target: None,
6680                noform: false,
6681                privileged: false,
6682                db_constraint: true,
6683                noedit: false,
6684                is_string_repr: false,
6685                max_length: 0,
6686                choices: Vec::new(),
6687                choice_labels: Vec::new(),
6688                default: String::new(),
6689                is_multichoice: false,
6690                unique: false,
6691                on_delete: crate::orm::FkAction::NoAction,
6692                on_update: crate::orm::FkAction::NoAction,
6693                index: false,
6694                auto_now_add,
6695                auto_now,
6696                trim: false,
6697                lowercase: false,
6698                case_insensitive: false,
6699                help: String::new(),
6700                example: String::new(),
6701                widget: None,
6702                supported_backends: Vec::new(),
6703                min: None,
6704                max: None,
6705                text_format: None,
6706                slug_from: None,
6707            };
6708
6709            // SQLite: the AddColumn op must produce TWO statements:
6710            // an ADD COLUMN nullable + an UPDATE backfill. The ADD
6711            // must NOT carry `NOT NULL` (otherwise SQLite rejects
6712            // it on the populated rows), and must NOT carry a
6713            // DEFAULT (otherwise SQLite rejects the non-constant).
6714            let op = Operation::AddColumn {
6715                table: "customer".to_string(),
6716                column: col.clone(),
6717            };
6718            let stmts = render_operation_sqlite(&op);
6719            assert_eq!(
6720                stmts.len(),
6721                2,
6722                "{label} SQLite: must emit ADD + UPDATE, got: {stmts:?}",
6723            );
6724            let add_sql = stmts[0].to_uppercase();
6725            assert!(
6726                add_sql.contains("ADD COLUMN"),
6727                "{label} SQLite: first stmt must be ADD COLUMN, got: {}",
6728                stmts[0],
6729            );
6730            assert!(
6731                !add_sql.contains("NOT NULL"),
6732                "{label} SQLite: ADD COLUMN must be nullable (NOT NULL = SQLite reject), got: {}",
6733                stmts[0],
6734            );
6735            assert!(
6736                !add_sql.contains("DEFAULT"),
6737                "{label} SQLite: ADD COLUMN must omit DEFAULT (non-constant = SQLite reject), got: {}",
6738                stmts[0],
6739            );
6740            let backfill_sql = &stmts[1];
6741            assert!(
6742                backfill_sql.contains("UPDATE") && backfill_sql.contains("datetime('now')"),
6743                "{label} SQLite: second stmt must be backfill UPDATE, got: {backfill_sql}",
6744            );
6745
6746            // Postgres: single ALTER with NOT NULL + DEFAULT now().
6747            let pstmts = render_operation_postgres(&op);
6748            assert_eq!(
6749                pstmts.len(),
6750                1,
6751                "{label} Postgres: single statement suffices, got: {pstmts:?}",
6752            );
6753            let p = &pstmts[0];
6754            assert!(
6755                p.to_lowercase().contains("default now()"),
6756                "{label} Postgres: expected DEFAULT now() in ALTER, got: {p}",
6757            );
6758            assert!(
6759                p.to_uppercase().contains("NOT NULL"),
6760                "{label} Postgres: keeps NOT NULL (Postgres allows non-constant defaults), got: {p}",
6761            );
6762        }
6763    }
6764
6765    /// Audit core-migrate #14 — raw DDL that interpolates
6766    /// developer-supplied identifiers must escape inner double quotes by
6767    /// doubling them (the quoting idiom used everywhere else), not strip
6768    /// or pass them through verbatim. A `"` in a table name previously
6769    /// produced malformed DDL in the multi-column index helper (ON-clause
6770    /// table was quote-stripped) and the M2M junction DDL (five raw
6771    /// interpolations).
6772    #[test]
6773    fn raw_ddl_escapes_quoted_identifiers() {
6774        // Multi-column index: the ON-clause table reference must carry
6775        // the doubled quote, not a stripped one.
6776        let idx = create_multi_index_stmt("we\"ird", &["a\"b".to_string(), "c".to_string()]);
6777        assert!(
6778            idx.contains("ON \"we\"\"ird\""),
6779            "multi-index ON clause must escape the quote (doubled); got: {idx}",
6780        );
6781        assert!(
6782            idx.contains("\"a\"\"b\""),
6783            "multi-index column list must escape the quote; got: {idx}",
6784        );
6785
6786        // M2M junction DDL: every interpolated identifier escapes its
6787        // inner quote. Check both backends.
6788        let op = Operation::CreateM2MTable {
6789            junction_table: "j\"t".to_string(),
6790            parent_table: "p\"t".to_string(),
6791            parent_col: "p\"c".to_string(),
6792            child_table: "c\"t".to_string(),
6793            child_col: "c\"c".to_string(),
6794            parent_ty: SqlType::BigInt,
6795            child_ty: SqlType::Text,
6796        };
6797        for backend in ["sqlite", "postgres"] {
6798            let sql = render_operation_for(&op, backend).join("\n");
6799            for (raw, escaped) in [
6800                ("j\"t", "\"j\"\"t\""),
6801                ("p\"t", "\"p\"\"t\""),
6802                ("p\"c", "\"p\"\"c\""),
6803                ("c\"t", "\"c\"\"t\""),
6804                ("c\"c", "\"c\"\"c\""),
6805            ] {
6806                assert!(
6807                    sql.contains(escaped),
6808                    "{backend}: identifier `{raw}` must render escaped as {escaped}; got: {sql}",
6809                );
6810            }
6811        }
6812    }
6813}