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