Skip to main content

umbral_core/
migrate.rs

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