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