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