Skip to main content

architect_sdk/
migration.rs

1//! Apply config to the database: DDL for schemas, enums, tables, indexes, and foreign keys.
2//! Order follows PostgreSQL dependencies (see docs/postgres-config-schema.md § 3.5).
3
4use crate::config::types::*;
5use crate::config::{validate, FullConfig};
6use crate::db::parse_canonical;
7use crate::db::pool::Pool;
8use crate::db::{ColumnFacts, DbSnapshot, Dialect};
9use crate::error::AppError;
10use serde::{Deserialize, Serialize};
11use std::collections::{HashMap, HashSet};
12
13fn quote(s: &str) -> String {
14    format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
15}
16
17/// Name of the column added to app tables when RLS is enabled. Used by migration and CRUD.
18pub const RLS_TENANT_COLUMN: &str = "tenant_id";
19
20/// Ensure every table in `config` has the RLS tenant column, row-level security enabled, and the
21/// four tenant-isolation policies. Tables flagged `global` instead get asymmetric policies — every
22/// tenant may read (`SELECT USING (true)`) but only the Platform Admin tenant may write — so they
23/// hold data shared across all RLS tenants. Fully idempotent (`ADD COLUMN IF NOT EXISTS`,
24/// `DROP POLICY IF EXISTS` then `CREATE POLICY`), so it is safe to run on both fresh installs and
25/// upgrades. On upgrades this is the path that backfills the tenant column + policies for newly
26/// created tables, which the diff-based migration plan does not touch.
27pub async fn apply_rls_to_tables(
28    pool: &Pool,
29    config: &FullConfig,
30    schema_override: Option<&str>,
31    rls_tenant_column: &str,
32    dialect: &dyn Dialect,
33) -> Result<(), AppError> {
34    if !dialect.supports_rls() {
35        tracing::warn!(dialect = %dialect.name(), "RLS requested but not supported by this dialect; skipping");
36        return Ok(());
37    }
38    let default_sid = config.schemas.first().map(|s| s.id.as_str()).unwrap_or("");
39    let platform_id = crate::tenant::platform_tenant_id();
40    let schemas_by_id: HashMap<_, _> = config.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
41    let columns_by_table: HashMap<_, Vec<&ColumnConfig>> =
42        config.columns.iter().fold(HashMap::new(), |mut m, c| {
43            m.entry(c.table_id.as_str()).or_default().push(c);
44            m
45        });
46    let col = rls_tenant_column;
47
48    for t in &config.tables {
49        let sid = t.schema_id.as_deref().unwrap_or(default_sid);
50        let schema = match schemas_by_id.get(sid) {
51            Some(s) => s,
52            None => continue,
53        };
54        let schema_name = quote(schema_override.unwrap_or(&schema.name));
55        let full_name = format!("{}.{}", schema_name, quote(&t.name));
56        let config_col_names: HashSet<&str> = columns_by_table
57            .get(t.id.as_str())
58            .map(|v| v.iter().map(|c| c.name.as_str()).collect())
59            .unwrap_or_default();
60
61        if !config_col_names.contains(col) {
62            let add_col = format!(
63                "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} TEXT",
64                full_name,
65                quote(col)
66            );
67            sqlx::query(&add_col).execute(pool).await?;
68        }
69        let enable_rls = format!("ALTER TABLE {} ENABLE ROW LEVEL SECURITY", full_name);
70        sqlx::query(&enable_rls).execute(pool).await?;
71        let q_col = quote(col);
72        let setting = "current_setting('app.tenant_id', true)";
73        // Tenant-isolated condition: a row belongs to the caller's tenant.
74        let tenant_cond = format!("{} = {}", q_col, setting);
75        // Platform-admin condition: the caller's session is the Platform Admin tenant. Keys off the
76        // session setting, not a row column, so it gates writes regardless of the row's tenant_id.
77        let admin_cond = format!("{} = '{}'", setting, platform_id.replace('\'', "''"));
78        let read_all = "true".to_string();
79        let policy_prefix = format!("rls_tenant_{}", t.name);
80        // Global tables: everyone reads (USING true), only the Platform Admin writes.
81        // Tenant tables: every operation is scoped to the caller's tenant_id.
82        let policies: Vec<(&str, &str, Option<&str>, Option<&str>)> = if t.global {
83            vec![
84                ("select", "SELECT", Some(read_all.as_str()), None),
85                ("insert", "INSERT", None, Some(admin_cond.as_str())),
86                (
87                    "update",
88                    "UPDATE",
89                    Some(admin_cond.as_str()),
90                    Some(admin_cond.as_str()),
91                ),
92                ("delete", "DELETE", Some(admin_cond.as_str()), None),
93            ]
94        } else {
95            vec![
96                ("select", "SELECT", Some(tenant_cond.as_str()), None),
97                ("insert", "INSERT", None, Some(tenant_cond.as_str())),
98                (
99                    "update",
100                    "UPDATE",
101                    Some(tenant_cond.as_str()),
102                    Some(tenant_cond.as_str()),
103                ),
104                ("delete", "DELETE", Some(tenant_cond.as_str()), None),
105            ]
106        };
107        for (suffix, cmd, using_cond, with_check) in policies.iter() {
108            let policy_name = format!("{}_{}", policy_prefix, suffix);
109            let drop_sql = format!(
110                "DROP POLICY IF EXISTS {} ON {}",
111                quote(&policy_name),
112                full_name
113            );
114            let _ = sqlx::query(&drop_sql).execute(pool).await;
115            let create_sql = match (using_cond, with_check) {
116                (Some(u), Some(w)) => format!(
117                    "CREATE POLICY {} ON {} FOR {} USING ( {} ) WITH CHECK ( {} )",
118                    quote(&policy_name),
119                    full_name,
120                    cmd,
121                    u,
122                    w
123                ),
124                (Some(u), None) => format!(
125                    "CREATE POLICY {} ON {} FOR {} USING ( {} )",
126                    quote(&policy_name),
127                    full_name,
128                    cmd,
129                    u
130                ),
131                (None, Some(w)) => format!(
132                    "CREATE POLICY {} ON {} FOR {} WITH CHECK ( {} )",
133                    quote(&policy_name),
134                    full_name,
135                    cmd,
136                    w
137                ),
138                (None, None) => continue,
139            };
140            sqlx::query(&create_sql).execute(pool).await?;
141        }
142    }
143    Ok(())
144}
145
146/// Add any of `desired` columns that the table does not have yet.
147///
148/// `CREATE TABLE IF NOT EXISTS` is a no-op on a table that already exists, so a table created by
149/// an earlier version of a package never picks up columns added since. This closes that gap and
150/// makes install/bootstrap idempotent in the same way an upgrade plan is.
151///
152/// Best-effort by design: a column that cannot be added (most often `NOT NULL` with no default on
153/// a table that already has rows) is retried as nullable and then logged, never returned as an
154/// error — refusing to install because of pre-existing drift helps nobody.
155async fn add_missing_columns(
156    pool: &Pool,
157    snapshot: &DbSnapshot,
158    dialect: &dyn Dialect,
159    schema: &str,
160    table: &str,
161    desired: &[(String, String)],
162) {
163    if !snapshot.introspected {
164        return;
165    }
166    let full = format!("{}.{}", quote(schema), quote(table));
167    for (name, def) in desired {
168        if snapshot.has_column(schema, table, name) {
169            continue;
170        }
171        let col_def = format!("{} {}", quote(name), def);
172        let sql = add_column_ddl(dialect, &full, &col_def);
173        match sqlx::query(&sql).execute(pool).await {
174            Ok(_) => {
175                tracing::info!(schema, table, column = %name, "added missing column to pre-existing table");
176            }
177            Err(e) => {
178                // NOT NULL without a default cannot be added to a populated table; fall back to a
179                // nullable column so reads and writes of that field at least work.
180                let upper = def.to_uppercase();
181                if upper.contains("NOT NULL") && !upper.contains("DEFAULT") {
182                    let relaxed_def = def.replace("NOT NULL", "").trim().to_string();
183                    let retry =
184                        add_column_ddl(dialect, &full, &format!("{} {}", quote(name), relaxed_def));
185                    if sqlx::query(&retry).execute(pool).await.is_ok() {
186                        tracing::warn!(schema, table, column = %name, "added missing column as NULLABLE — NOT NULL could not be applied to the existing table");
187                        continue;
188                    }
189                }
190                tracing::warn!(schema, table, column = %name, error = %e, "could not add missing column to pre-existing table");
191            }
192        }
193    }
194}
195
196/// Source columns as a companion (`_audit` / `_history`) table replicates them: same type,
197/// always nullable, no constraints. Mirrors `audit_table_ddl` / `history_table_ddl`.
198fn companion_source_columns(
199    source_cols: &[&ColumnConfig],
200    dialect: &dyn Dialect,
201) -> Vec<(String, String)> {
202    let mut out: Vec<(String, String)> = source_cols
203        .iter()
204        .map(|c| (c.name.clone(), dialect.ddl_type(&parse_canonical(&c.type_))))
205        .collect();
206    let config_col_names: HashSet<&str> = source_cols.iter().map(|c| c.name.as_str()).collect();
207    let audit_ts = dialect.audit_timestamp_type();
208    for (name, typ) in [
209        ("created_at", audit_ts),
210        ("updated_at", audit_ts),
211        ("archived_at", audit_ts),
212        ("created_by", "TEXT"),
213        ("updated_by", "TEXT"),
214    ] {
215        if !config_col_names.contains(name) {
216            out.push((name.to_string(), typ.to_string()));
217        }
218    }
219    out
220}
221
222/// Apply full config to the database: CREATE SCHEMA, CREATE TYPE, CREATE TABLE, CREATE INDEX, ADD FK.
223/// Validates config first. Idempotent for schemas and types (IF NOT EXISTS); tables are CREATE TABLE only (fails if exists).
224/// When `schema_override` is `Some(s)`, app tables/indexes/FKs are created in schema `s` instead of config schema names (e.g. for schema-strategy tenants).
225/// When `rls_tenant_column` is `Some(col)`, each table gets that column (if missing), RLS enabled, and policies using `current_setting('app.tenant_id', true)`.
226pub async fn apply_migrations(
227    pool: &Pool,
228    config: &FullConfig,
229    schema_override: Option<&str>,
230    rls_tenant_column: Option<&str>,
231    dialect: &dyn Dialect,
232    cross_package_configs: &HashMap<String, FullConfig>,
233) -> Result<(), AppError> {
234    validate(config)?;
235    let default_sid = config
236        .schemas
237        .first()
238        .map(|s| s.id.as_str())
239        .ok_or_else(|| {
240            AppError::Config(crate::error::ConfigError::Validation(
241                "at least one schema required".into(),
242            ))
243        })?;
244
245    if dialect.supports_schemas() {
246        if let Some(s) = schema_override {
247            let name = quote(s);
248            sqlx::query(&format!("CREATE SCHEMA IF NOT EXISTS {}", name))
249                .execute(pool)
250                .await?;
251        }
252    }
253
254    let schemas_by_id: HashMap<_, _> = config.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
255    let tables_by_id: HashMap<_, _> = config.tables.iter().map(|t| (t.id.as_str(), t)).collect();
256    let columns_by_table: HashMap<_, Vec<&ColumnConfig>> =
257        config.columns.iter().fold(HashMap::new(), |mut m, c| {
258            m.entry(c.table_id.as_str()).or_default().push(c);
259            m
260        });
261
262    // When schema_override is set, we only create the override schema; otherwise create config schemas.
263    if schema_override.is_none() && dialect.supports_schemas() {
264        for s in &config.schemas {
265            let name = quote(&s.name);
266            let comment = s
267                .comment
268                .as_ref()
269                .map(|c| format!("COMMENT ON SCHEMA {} IS '{}'", name, c.replace('\'', "''")));
270            sqlx::query(&format!("CREATE SCHEMA IF NOT EXISTS {}", name))
271                .execute(pool)
272                .await?;
273            if let Some(sql) = comment {
274                let _ = sqlx::query(&sql).execute(pool).await;
275            }
276        }
277    }
278
279    for e in &config.enums {
280        let sid = e.schema_id.as_deref().unwrap_or(default_sid);
281        let schema = schemas_by_id.get(sid).ok_or_else(|| {
282            AppError::Config(crate::error::ConfigError::MissingReference {
283                kind: "schema",
284                id: sid.to_string(),
285            })
286        })?;
287        let schema_name = quote(schema_override.unwrap_or(&schema.name));
288        let type_name = quote(&e.name);
289        if dialect.supports_named_enum_types() {
290            let values: Vec<String> = e
291                .values
292                .iter()
293                .map(|v| format!("'{}'", v.replace('\'', "''")))
294                .collect();
295            let sql = format!(
296                "CREATE TYPE {}.{} AS ENUM ({})",
297                schema_name,
298                type_name,
299                values.join(", ")
300            );
301            let _ = sqlx::query(&sql).execute(pool).await;
302        }
303    }
304
305    // Which tables already exist, and with which columns? `CREATE TABLE IF NOT EXISTS` silently
306    // leaves a pre-existing table alone, so without this a table created by an older version of
307    // the package would never gain the columns added since. Taken before any DDL runs, so
308    // `has_table` means "existed before this call".
309    let target_schemas: Vec<String> = match schema_override {
310        Some(o) => vec![o.to_string()],
311        None => config.schemas.iter().map(|s| s.name.clone()).collect(),
312    };
313    let pre_snapshot = crate::db::introspect(pool, dialect, &target_schemas).await;
314
315    for t in &config.tables {
316        let sid = t.schema_id.as_deref().unwrap_or(default_sid);
317        let schema = schemas_by_id.get(sid).ok_or_else(|| {
318            AppError::Config(crate::error::ConfigError::MissingReference {
319                kind: "schema",
320                id: sid.to_string(),
321            })
322        })?;
323        let schema_raw = schema_override.unwrap_or(&schema.name);
324        let schema_name = quote(schema_raw);
325        let table_name = quote(&t.name);
326        let full_name = format!("{}.{}", schema_name, table_name);
327        let table_pre_existed = pre_snapshot.has_table(schema_raw, &t.name);
328
329        let cols = columns_by_table
330            .get(t.id.as_str())
331            .map(|v| v.as_slice())
332            .unwrap_or(&[]);
333        // (column name, DDL suffix) pairs — reused below to add columns a pre-existing table
334        // is missing, so CREATE TABLE and ALTER TABLE can never disagree about a definition.
335        let mut columns: Vec<(String, String)> = Vec::new();
336        for c in cols {
337            let typ = dialect.ddl_type(&parse_canonical(&c.type_));
338            let mut def = typ;
339            if !c.nullable {
340                def.push_str(" NOT NULL");
341            }
342            if let Some(ref d) = c.default {
343                def.push_str(" DEFAULT ");
344                match d {
345                    ColumnDefaultConfig::Literal(s) => def.push_str(s),
346                    ColumnDefaultConfig::Expression { expression } => def.push_str(expression),
347                }
348            }
349            columns.push((c.name.clone(), def));
350        }
351
352        let config_col_names: HashSet<&str> = cols.iter().map(|c| c.name.as_str()).collect();
353        let ts_default = format!(
354            "{} NOT NULL DEFAULT {}",
355            dialect.sys_timestamp_type(),
356            dialect.now_fn()
357        );
358        let ts_nullable = dialect.sys_timestamp_type().to_string();
359        for (name, def_suffix) in [
360            ("created_at", ts_default.as_str()),
361            ("updated_at", ts_default.as_str()),
362            ("archived_at", ts_nullable.as_str()),
363            ("created_by", "TEXT"),
364            ("updated_by", "TEXT"),
365        ] {
366            if !config_col_names.contains(name) {
367                columns.push((name.to_string(), def_suffix.to_string()));
368            }
369        }
370
371        let mut col_defs: Vec<String> = columns
372            .iter()
373            .map(|(name, def)| format!("{} {}", quote(name), def))
374            .collect();
375
376        let pk_cols = match &t.primary_key {
377            PrimaryKeyConfig::Single(s) => vec![quote(s)],
378            PrimaryKeyConfig::Composite(v) => v.iter().map(|s| quote(s)).collect::<Vec<_>>(),
379        };
380        let pk_def = format!("PRIMARY KEY ({})", pk_cols.join(", "));
381        col_defs.push(pk_def);
382
383        for u in &t.unique {
384            let cols: Vec<String> = u.iter().map(|s| quote(s)).collect();
385            col_defs.push(format!("UNIQUE ({})", cols.join(", ")));
386        }
387        for ch in &t.check {
388            col_defs.push(format!(
389                "CONSTRAINT {} CHECK ({})",
390                quote(&ch.name),
391                ch.expression
392            ));
393        }
394
395        let sql = format!(
396            "CREATE TABLE IF NOT EXISTS {} (\n  {}\n)",
397            full_name,
398            col_defs.join(",\n  ")
399        );
400        sqlx::query(&sql).execute(pool).await?;
401
402        if table_pre_existed {
403            add_missing_columns(pool, &pre_snapshot, dialect, schema_raw, &t.name, &columns).await;
404        }
405
406        if t.audit_log {
407            let audit_sql = audit_table_ddl(schema_raw, &t.name, cols, dialect);
408            sqlx::query(&audit_sql).execute(pool).await?;
409            let pk_col = match &t.primary_key {
410                PrimaryKeyConfig::Single(s) => s.clone(),
411                PrimaryKeyConfig::Composite(v) => v[0].clone(),
412            };
413            let audit_full = format!(
414                "{}.{}",
415                quote(schema_raw),
416                quote(&format!("{}_audit", t.name))
417            );
418            let idx_sql = format!(
419                "CREATE INDEX IF NOT EXISTS {} ON {} ({}, {})",
420                quote(&format!("{}_audit_record_idx", t.name)),
421                audit_full,
422                quote(&pk_col),
423                quote("audit_at")
424            );
425            let _ = sqlx::query(&idx_sql).execute(pool).await;
426
427            let audit_table = format!("{}_audit", t.name);
428            if pre_snapshot.has_table(schema_raw, &audit_table) {
429                add_missing_columns(
430                    pool,
431                    &pre_snapshot,
432                    dialect,
433                    schema_raw,
434                    &audit_table,
435                    &companion_source_columns(cols, dialect),
436                )
437                .await;
438            }
439        }
440
441        if t.versioning.as_ref().is_some_and(|v| v.enabled) {
442            let pk_col = match &t.primary_key {
443                PrimaryKeyConfig::Single(s) => s.clone(),
444                PrimaryKeyConfig::Composite(v) => v[0].clone(),
445            };
446            let history_ddl = history_table_ddl(schema_raw, &t.name, &pk_col, cols, dialect);
447            // history_table_ddl embeds a comment line for the index; execute CREATE TABLE only
448            let create_only = history_ddl
449                .lines()
450                .take_while(|l| !l.trim_start().starts_with("-- index:"))
451                .collect::<Vec<_>>()
452                .join("\n");
453            sqlx::query(create_only.trim()).execute(pool).await?;
454            let idx_sql = history_index_ddl(schema_raw, &t.name, &pk_col);
455            let _ = sqlx::query(&idx_sql).execute(pool).await;
456
457            let history_table = format!("{}_history", t.name);
458            if pre_snapshot.has_table(schema_raw, &history_table) {
459                add_missing_columns(
460                    pool,
461                    &pre_snapshot,
462                    dialect,
463                    schema_raw,
464                    &history_table,
465                    &companion_source_columns(cols, dialect),
466                )
467                .await;
468            }
469        }
470    }
471
472    if let Some(col) = rls_tenant_column {
473        apply_rls_to_tables(pool, config, schema_override, col, dialect).await?;
474    }
475
476    for idx in &config.indexes {
477        let sid = idx.schema_id.as_deref().unwrap_or(default_sid);
478        let schema = schemas_by_id.get(sid).ok_or_else(|| {
479            AppError::Config(crate::error::ConfigError::MissingReference {
480                kind: "schema",
481                id: sid.to_string(),
482            })
483        })?;
484        let table = tables_by_id.get(idx.table_id.as_str()).ok_or_else(|| {
485            AppError::Config(crate::error::ConfigError::MissingReference {
486                kind: "table",
487                id: idx.table_id.clone(),
488            })
489        })?;
490        let schema_name = quote(schema_override.unwrap_or(&schema.name));
491        let table_name = quote(&table.name);
492        let full_table = format!("{}.{}", schema_name, table_name);
493        let index_name = quote(&idx.name);
494
495        let mut col_parts: Vec<String> = Vec::new();
496        for col in &idx.columns {
497            match col {
498                IndexColumnEntry::Name(n) => col_parts.push(quote(n)),
499                IndexColumnEntry::Spec {
500                    name, direction, ..
501                } => {
502                    let dir = direction
503                        .as_deref()
504                        .map(|d| format!(" {}", d.to_uppercase()))
505                        .unwrap_or_default();
506                    col_parts.push(format!("{}{}", quote(name), dir));
507                }
508                IndexColumnEntry::Expression { expression } => col_parts.push(expression.clone()),
509            }
510        }
511        let method = idx.method.as_deref().unwrap_or("btree");
512        let unique = if idx.unique { "UNIQUE " } else { "" };
513        let include: String = if idx.include.is_empty() {
514            String::new()
515        } else {
516            let inc: Vec<String> = idx.include.iter().map(|s| quote(s)).collect();
517            format!(" INCLUDE ({})", inc.join(", "))
518        };
519        let where_clause: String = idx
520            .where_
521            .as_ref()
522            .map(|w| format!(" WHERE {}", w))
523            .unwrap_or_default();
524
525        let sql = format!(
526            "CREATE {}INDEX IF NOT EXISTS {} ON {} USING {} ({}){}{}",
527            unique,
528            index_name,
529            full_table,
530            method,
531            col_parts.join(", "),
532            include,
533            where_clause
534        );
535        let _ = sqlx::query(&sql).execute(pool).await;
536    }
537
538    for rel in &config.relationships {
539        let from_sid = rel.from_schema_id.as_deref().unwrap_or(default_sid);
540        let from_schema = schemas_by_id.get(from_sid).ok_or_else(|| {
541            AppError::Config(crate::error::ConfigError::MissingReference {
542                kind: "schema",
543                id: from_sid.to_string(),
544            })
545        })?;
546        let from_table = tables_by_id
547            .get(rel.from_table_id.as_str())
548            .ok_or_else(|| {
549                AppError::Config(crate::error::ConfigError::MissingReference {
550                    kind: "table",
551                    id: rel.from_table_id.clone(),
552                })
553            })?;
554
555        // Resolve the target schema and table — either from a cross-package config or this config.
556        let (to_schema_name_owned, to_table_name, to_col_name) = if let Some(pkg_id) =
557            rel.to_package_id.as_deref()
558        {
559            let foreign = cross_package_configs.get(pkg_id).ok_or_else(|| {
560                AppError::Config(crate::error::ConfigError::MissingReference {
561                    kind: "cross_package",
562                    id: pkg_id.to_string(),
563                })
564            })?;
565            let foreign_tables: HashMap<_, _> =
566                foreign.tables.iter().map(|t| (t.id.as_str(), t)).collect();
567            let foreign_schemas: HashMap<_, _> =
568                foreign.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
569            let to_tbl = foreign_tables
570                .get(rel.to_table_id.as_str())
571                .ok_or_else(|| {
572                    AppError::Config(crate::error::ConfigError::MissingReference {
573                        kind: "table",
574                        id: rel.to_table_id.clone(),
575                    })
576                })?;
577            let foreign_default_sid = foreign.schemas.first().map(|s| s.id.as_str()).unwrap_or("");
578            let to_sid = rel.to_schema_id.as_deref().unwrap_or(foreign_default_sid);
579            let to_schema = foreign_schemas.get(to_sid).ok_or_else(|| {
580                AppError::Config(crate::error::ConfigError::MissingReference {
581                    kind: "schema",
582                    id: to_sid.to_string(),
583                })
584            })?;
585            let col_name = foreign
586                .columns
587                .iter()
588                .find(|c| c.id == rel.to_column_id)
589                .map(|c| c.name.clone())
590                .ok_or_else(|| {
591                    AppError::Config(crate::error::ConfigError::MissingReference {
592                        kind: "column",
593                        id: rel.to_column_id.clone(),
594                    })
595                })?;
596            // Cross-package FKs always use the real schema name (no schema_override — the
597            // target package lives in its own schema, not the tenant override).
598            (to_schema.name.clone(), to_tbl.name.clone(), col_name)
599        } else {
600            let to_sid = rel.to_schema_id.as_deref().unwrap_or(default_sid);
601            let to_schema = schemas_by_id.get(to_sid).ok_or_else(|| {
602                AppError::Config(crate::error::ConfigError::MissingReference {
603                    kind: "schema",
604                    id: to_sid.to_string(),
605                })
606            })?;
607            let to_table = tables_by_id.get(rel.to_table_id.as_str()).ok_or_else(|| {
608                AppError::Config(crate::error::ConfigError::MissingReference {
609                    kind: "table",
610                    id: rel.to_table_id.clone(),
611                })
612            })?;
613            let col_name = config
614                .columns
615                .iter()
616                .find(|c| c.id == rel.to_column_id)
617                .map(|c| c.name.clone())
618                .ok_or_else(|| {
619                    AppError::Config(crate::error::ConfigError::MissingReference {
620                        kind: "column",
621                        id: rel.to_column_id.clone(),
622                    })
623                })?;
624            (
625                schema_override.unwrap_or(&to_schema.name).to_string(),
626                to_table.name.clone(),
627                col_name,
628            )
629        };
630
631        let from_schema_name = schema_override.unwrap_or(&from_schema.name);
632        let from_col = config
633            .columns
634            .iter()
635            .find(|c| c.id == rel.from_column_id)
636            .map(|c| c.name.as_str())
637            .ok_or_else(|| {
638                AppError::Config(crate::error::ConfigError::MissingReference {
639                    kind: "column",
640                    id: rel.from_column_id.clone(),
641                })
642            })?;
643
644        let from_full = format!("{}.{}", quote(from_schema_name), quote(&from_table.name));
645        let to_full = format!("{}.{}", quote(&to_schema_name_owned), quote(&to_table_name));
646        let constraint_name = rel.name.as_deref().unwrap_or(&rel.id);
647        let on_update = rel.on_update.as_deref().unwrap_or("NO ACTION");
648        let on_delete = rel.on_delete.as_deref().unwrap_or("NO ACTION");
649
650        let sql = format!(
651            "ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({}) ON UPDATE {} ON DELETE {}",
652            from_full,
653            quote(constraint_name),
654            quote(from_col),
655            to_full,
656            quote(&to_col_name),
657            on_update,
658            on_delete
659        );
660        let _ = sqlx::query(&sql).execute(pool).await;
661    }
662
663    Ok(())
664}
665
666/// Revert migrations for a package: drop tables, enum types, and schema (if not public) in reverse order of apply.
667/// Uses the same schema_override as apply_migrations (tables/enums live in that schema).
668pub async fn revert_migrations(
669    pool: &Pool,
670    config: &FullConfig,
671    schema_override: Option<&str>,
672) -> Result<(), AppError> {
673    let default_sid = config
674        .schemas
675        .first()
676        .map(|s| s.id.as_str())
677        .ok_or_else(|| {
678            AppError::Config(crate::error::ConfigError::Validation(
679                "at least one schema required".into(),
680            ))
681        })?;
682
683    let schemas_by_id: HashMap<_, _> = config.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
684
685    // 1. Drop tables (CASCADE drops FKs and dependent objects)
686    for t in &config.tables {
687        let sid = t.schema_id.as_deref().unwrap_or(default_sid);
688        let schema = schemas_by_id.get(sid).ok_or_else(|| {
689            AppError::Config(crate::error::ConfigError::MissingReference {
690                kind: "schema",
691                id: sid.to_string(),
692            })
693        })?;
694        let schema_raw = schema_override.unwrap_or(&schema.name);
695        let schema_name = quote(schema_raw);
696        let table_name = quote(&t.name);
697        let full_name = format!("{}.{}", schema_name, table_name);
698        if t.audit_log {
699            let audit_full = format!("{}.{}", schema_name, quote(&format!("{}_audit", t.name)));
700            let _ = sqlx::query(&format!("DROP TABLE IF EXISTS {} CASCADE", audit_full))
701                .execute(pool)
702                .await;
703        }
704        if t.versioning.as_ref().is_some_and(|v| v.enabled) {
705            let history_full = format!("{}.{}", schema_name, quote(&format!("{}_history", t.name)));
706            let _ = sqlx::query(&format!("DROP TABLE IF EXISTS {} CASCADE", history_full))
707                .execute(pool)
708                .await;
709        }
710        let drop_sql = format!("DROP TABLE IF EXISTS {} CASCADE", full_name);
711        let _ = sqlx::query(&drop_sql).execute(pool).await;
712    }
713
714    // 2. Drop enum types
715    for e in &config.enums {
716        let sid = e.schema_id.as_deref().unwrap_or(default_sid);
717        let schema = schemas_by_id.get(sid).ok_or_else(|| {
718            AppError::Config(crate::error::ConfigError::MissingReference {
719                kind: "schema",
720                id: sid.to_string(),
721            })
722        })?;
723        let schema_name = quote(schema_override.unwrap_or(&schema.name));
724        let type_name = quote(&e.name);
725        let drop_sql = format!("DROP TYPE IF EXISTS {}.{} CASCADE", schema_name, type_name);
726        let _ = sqlx::query(&drop_sql).execute(pool).await;
727    }
728
729    // 3. Drop schema only if not public (shared schema)
730    if schema_override.is_none() {
731        for s in &config.schemas {
732            if s.name.eq_ignore_ascii_case("public") {
733                continue;
734            }
735            let schema_name = quote(&s.name);
736            let drop_sql = format!("DROP SCHEMA IF EXISTS {} CASCADE", schema_name);
737            let _ = sqlx::query(&drop_sql).execute(pool).await;
738        }
739    }
740
741    Ok(())
742}
743
744/// `ALTER TABLE … ADD COLUMN` with an `IF NOT EXISTS` guard where the dialect supports it.
745///
746/// The guard makes the step a no-op when the column is already present — the common case when a
747/// plan is replayed after a partial failure, or broadcast to a tenant database that already has
748/// the column. Dialects without the syntax (MySQL, SQLite) rely on the executor's pre-flight
749/// introspection instead (see `db::introspect`).
750fn add_column_ddl(dialect: &dyn Dialect, full_table: &str, col_def: &str) -> String {
751    if dialect.supports_add_column_if_not_exists() {
752        format!(
753            "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {}",
754            full_table, col_def
755        )
756    } else {
757        format!("ALTER TABLE {} ADD COLUMN {}", full_table, col_def)
758    }
759}
760
761// ─── Migration plan types ────────────────────────────────────────────────────
762
763#[derive(Debug, Clone, Serialize, Deserialize)]
764#[serde(rename_all = "snake_case")]
765pub enum MigrationOperation {
766    CreateSchema,
767    CreateEnum,
768    DropEnum,
769    AddEnumValue,
770    RemoveEnumValue,
771    CreateTable,
772    DropTable,
773    AddColumn,
774    DropColumn,
775    RenameColumn,
776    AlterColumnType,
777    BackfillNulls,
778    SetNotNull,
779    DropNotNull,
780    SetDefault,
781    DropDefault,
782    CreateIndex,
783    DropIndex,
784    AddForeignKey,
785    DropForeignKey,
786}
787
788impl std::fmt::Display for MigrationOperation {
789    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
790        let s = serde_json::to_value(self)
791            .ok()
792            .and_then(|v| v.as_str().map(String::from))
793            .unwrap_or_else(|| format!("{:?}", self));
794        write!(f, "{}", s)
795    }
796}
797
798/// How safely a migration step can be executed.
799#[derive(Debug, Clone, Serialize, Deserialize)]
800#[serde(rename_all = "snake_case")]
801pub enum MigrationSafety {
802    /// Guaranteed to succeed, no data impact.
803    Safe,
804    /// Attempted; execution failure is captured as a warning instead of aborting.
805    BestEffort,
806    /// No DDL generated — config change noted as a warning only (e.g. removed tables/columns).
807    WarnOnly,
808}
809
810/// Risk category associated with a migration step.
811#[derive(Debug, Clone, Serialize, Deserialize)]
812#[serde(rename_all = "snake_case")]
813pub enum MigrationRisk {
814    None,
815    /// Cast may fail for incompatible values (e.g. TEXT → INTEGER).
816    MayFail,
817    /// SET NOT NULL will fail if any existing row has NULL in this column.
818    ExistingNullsMustBeAbsent,
819    /// Existing NULL rows will be overwritten with the column default.
820    DataWillBeModified,
821    /// Cannot be automated — requires a manual database action.
822    ManualActionRequired,
823}
824
825/// One step in a migration plan: a DDL statement with metadata.
826#[derive(Debug, Clone, Serialize, Deserialize)]
827pub struct MigrationStep {
828    pub step: usize,
829    pub operation: MigrationOperation,
830    pub schema: String,
831    pub table: Option<String>,
832    /// Column name, index name, FK constraint name, enum name, etc.
833    pub object: String,
834    /// Previous name of `object` for rename operations. `None` for every other operation.
835    /// Optional in the serialized form so migration plans saved before this field existed
836    /// still deserialize.
837    #[serde(default, skip_serializing_if = "Option::is_none")]
838    pub from_object: Option<String>,
839    /// "column" | "table" | "index" | "foreign_key" | "enum" | "enum_value" | "schema"
840    pub object_type: String,
841    pub description: String,
842    /// The SQL to execute. None for WarnOnly steps.
843    pub ddl: Option<String>,
844    pub safety: MigrationSafety,
845    pub risk: MigrationRisk,
846    pub risk_detail: Option<String>,
847}
848
849/// Computed diff between two package versions expressed as ordered migration steps.
850#[derive(Debug, Clone, Serialize, Deserialize)]
851pub struct MigrationPlan {
852    pub steps: Vec<MigrationStep>,
853}
854
855#[derive(Debug, Clone, Serialize)]
856pub struct MigrationSummary {
857    pub total: usize,
858    pub safe: usize,
859    pub best_effort: usize,
860    pub warn_only: usize,
861}
862
863impl MigrationPlan {
864    pub fn summary(&self) -> MigrationSummary {
865        let (mut safe, mut best_effort, mut warn_only) = (0, 0, 0);
866        for s in &self.steps {
867            match s.safety {
868                MigrationSafety::Safe => safe += 1,
869                MigrationSafety::BestEffort => best_effort += 1,
870                MigrationSafety::WarnOnly => warn_only += 1,
871            }
872        }
873        MigrationSummary {
874            total: self.steps.len(),
875            safe,
876            best_effort,
877            warn_only,
878        }
879    }
880}
881
882/// Result returned by `execute_migration_plan`.
883pub struct MigrationExecutionResult {
884    pub applied: usize,
885    pub warned: usize,
886    /// Steps whose effect was already present in the database and were not re-run.
887    pub skipped: usize,
888    pub warnings: Vec<String>,
889    /// One human-readable line per skipped step.
890    pub skips: Vec<String>,
891}
892
893fn default_str(d: &ColumnDefaultConfig) -> String {
894    match d {
895        ColumnDefaultConfig::Literal(s) => s.clone(),
896        ColumnDefaultConfig::Expression { expression } => expression.clone(),
897    }
898}
899
900/// A column whose type is (an array of) a given enum — i.e. one that must be recast when the
901/// enum type is rebuilt.
902struct EnumColumnRef {
903    schema: String,
904    table: String,
905    column: String,
906    default: Option<String>,
907    is_array: bool,
908}
909
910/// If `t` is a custom enum reference (`schema.name`, bare `name`, or an array of one), return
911/// `(enum_name, is_array)` where `enum_name` is the unqualified type name. None for built-in types.
912fn enum_type_name(t: &crate::db::CanonicalType) -> Option<(String, bool)> {
913    use crate::db::CanonicalType;
914    let unqualified = |s: &str| s.rsplit('.').next().unwrap_or(s).to_string();
915    match t {
916        CanonicalType::Custom(s) => Some((unqualified(s), false)),
917        CanonicalType::Array(inner) => match inner.as_ref() {
918            CanonicalType::Custom(s) => Some((unqualified(s), true)),
919            _ => None,
920        },
921        _ => None,
922    }
923}
924
925/// Find every column in `new` whose type is `new_enum` (or an array of it), resolved to its live
926/// table/schema name. Used to drive the recast steps of an enum rebuild.
927fn enum_dependent_columns(
928    new_enum: &EnumConfig,
929    new: &FullConfig,
930    new_tables: &HashMap<&str, &TableConfig>,
931    new_schemas: &HashMap<&str, &SchemaConfig>,
932    schema_override: Option<&str>,
933) -> Vec<EnumColumnRef> {
934    let default_sid = new.schemas.first().map(|s| s.id.as_str()).unwrap_or("");
935    let mut out = Vec::new();
936    for c in &new.columns {
937        let Some((tyname, is_array)) = enum_type_name(&parse_canonical(&c.type_)) else {
938            continue;
939        };
940        if tyname != new_enum.name {
941            continue;
942        }
943        let Some(table) = new_tables.get(c.table_id.as_str()) else {
944            continue;
945        };
946        let tsid = table.schema_id.as_deref().unwrap_or(default_sid);
947        let schema = schema_override.map(String::from).unwrap_or_else(|| {
948            new_schemas
949                .get(tsid)
950                .map(|s| s.name.clone())
951                .unwrap_or_else(|| tsid.to_string())
952        });
953        out.push(EnumColumnRef {
954            schema,
955            table: table.name.clone(),
956            column: c.name.clone(),
957            default: c.default.as_ref().map(default_str),
958            is_array,
959        });
960    }
961    out
962}
963
964/// PostgreSQL cannot drop a value from an enum in place, so when one is removed the type must be
965/// rebuilt: rename the live type aside, create it afresh with the reduced value set, recast every
966/// dependent column through a text cast, then drop the old type. Steps are emitted as `BestEffort`
967/// — a recast that fails because a row still holds a removed value is surfaced as a warning rather
968/// than aborting the whole upgrade. Each statement is a separate step because `execute_migration_plan`
969/// runs them individually (the extended protocol forbids multiple statements per query).
970fn recreate_enum_steps(
971    steps: &mut Vec<MigrationStep>,
972    schema: &str,
973    new_enum: &EnumConfig,
974    removed: &[&str],
975    dependents: &[EnumColumnRef],
976) {
977    let type_q = format!("{}.{}", quote(schema), quote(&new_enum.name));
978    let tmp_name = format!("{}__arch_old", new_enum.name);
979    let values: Vec<String> = new_enum
980        .values
981        .iter()
982        .map(|v| format!("'{}'", v.replace('\'', "''")))
983        .collect();
984
985    // 0. Informational summary of the destructive rebuild (no DDL).
986    steps.push(MigrationStep {
987        step: 0,
988        operation: MigrationOperation::RemoveEnumValue,
989        schema: schema.to_string(),
990        table: None,
991        object: format!("{}:{}", new_enum.name, removed.join(",")),
992        object_type: "enum".into(),
993        from_object: None,
994        description: format!(
995            "Rebuild enum \"{}\".\"{}\" to remove value(s): {}",
996            schema,
997            new_enum.name,
998            removed.join(", ")
999        ),
1000        ddl: None,
1001        safety: MigrationSafety::WarnOnly,
1002        risk: MigrationRisk::ManualActionRequired,
1003        risk_detail: Some(format!(
1004            "PostgreSQL cannot drop enum values in place. The type is rebuilt and {} dependent \
1005             column(s) are recast via a text cast. Any existing row holding a removed value ({}) \
1006             will make its recast fail — reassign those rows first.",
1007            dependents.len(),
1008            removed.join(", ")
1009        )),
1010    });
1011
1012    // 1. Rename the live type aside.
1013    steps.push(MigrationStep {
1014        step: 0,
1015        operation: MigrationOperation::DropEnum,
1016        schema: schema.to_string(),
1017        table: None,
1018        object: new_enum.name.clone(),
1019        object_type: "enum".into(),
1020        from_object: None,
1021        description: format!(
1022            "Rename enum \"{}\".\"{}\" to \"{}\" before rebuild",
1023            schema, new_enum.name, tmp_name
1024        ),
1025        ddl: Some(format!(
1026            "ALTER TYPE {} RENAME TO {}",
1027            type_q,
1028            quote(&tmp_name)
1029        )),
1030        safety: MigrationSafety::BestEffort,
1031        risk: MigrationRisk::None,
1032        risk_detail: None,
1033    });
1034
1035    // 2. Create the type afresh with the reduced value set (folds in any added values too).
1036    steps.push(MigrationStep {
1037        step: 0,
1038        operation: MigrationOperation::CreateEnum,
1039        schema: schema.to_string(),
1040        table: None,
1041        object: new_enum.name.clone(),
1042        object_type: "enum".into(),
1043        from_object: None,
1044        description: format!(
1045            "Recreate enum \"{}\".\"{}\" with {} value(s)",
1046            schema,
1047            new_enum.name,
1048            new_enum.values.len()
1049        ),
1050        ddl: Some(format!(
1051            "CREATE TYPE {} AS ENUM ({})",
1052            type_q,
1053            values.join(", ")
1054        )),
1055        safety: MigrationSafety::BestEffort,
1056        risk: MigrationRisk::None,
1057        risk_detail: None,
1058    });
1059
1060    // 3. Recast every dependent column from the renamed type onto the rebuilt one. A column with a
1061    //    default must drop it first (the default still references the renamed type) and restore it after.
1062    for dep in dependents {
1063        let table_q = format!("{}.{}", quote(&dep.schema), quote(&dep.table));
1064        let col_q = quote(&dep.column);
1065
1066        if dep.default.is_some() {
1067            steps.push(MigrationStep {
1068                step: 0,
1069                operation: MigrationOperation::DropDefault,
1070                schema: dep.schema.clone(),
1071                table: Some(dep.table.clone()),
1072                object: dep.column.clone(),
1073                object_type: "column".into(),
1074                from_object: None,
1075                description: format!(
1076                    "Drop default on {}.{} before enum recast",
1077                    dep.table, dep.column
1078                ),
1079                ddl: Some(format!(
1080                    "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT",
1081                    table_q, col_q
1082                )),
1083                safety: MigrationSafety::BestEffort,
1084                risk: MigrationRisk::None,
1085                risk_detail: None,
1086            });
1087        }
1088
1089        let (col_type, using) = if dep.is_array {
1090            (
1091                format!("{}[]", type_q),
1092                format!("{}::text[]::{}[]", col_q, type_q),
1093            )
1094        } else {
1095            (type_q.clone(), format!("{}::text::{}", col_q, type_q))
1096        };
1097        steps.push(MigrationStep {
1098            step: 0,
1099            operation: MigrationOperation::AlterColumnType,
1100            schema: dep.schema.clone(),
1101            table: Some(dep.table.clone()),
1102            object: dep.column.clone(),
1103            object_type: "column".into(),
1104            from_object: None,
1105            description: format!(
1106                "Recast {}.{} onto rebuilt enum \"{}\"",
1107                dep.table, dep.column, new_enum.name
1108            ),
1109            ddl: Some(format!(
1110                "ALTER TABLE {} ALTER COLUMN {} TYPE {} USING {}",
1111                table_q, col_q, col_type, using
1112            )),
1113            safety: MigrationSafety::BestEffort,
1114            risk: MigrationRisk::MayFail,
1115            risk_detail: Some(format!(
1116                "Cast fails if any row holds a removed value ({}).",
1117                removed.join(", ")
1118            )),
1119        });
1120
1121        if let Some(def) = &dep.default {
1122            steps.push(MigrationStep {
1123                step: 0,
1124                operation: MigrationOperation::SetDefault,
1125                schema: dep.schema.clone(),
1126                table: Some(dep.table.clone()),
1127                object: dep.column.clone(),
1128                object_type: "column".into(),
1129                from_object: None,
1130                description: format!(
1131                    "Restore default on {}.{} after enum recast",
1132                    dep.table, dep.column
1133                ),
1134                ddl: Some(format!(
1135                    "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {}",
1136                    table_q, col_q, def
1137                )),
1138                safety: MigrationSafety::BestEffort,
1139                risk: MigrationRisk::None,
1140                risk_detail: None,
1141            });
1142        }
1143    }
1144
1145    // 4. Drop the renamed original type.
1146    steps.push(MigrationStep {
1147        step: 0,
1148        operation: MigrationOperation::DropEnum,
1149        schema: schema.to_string(),
1150        table: None,
1151        object: tmp_name.clone(),
1152        object_type: "enum".into(),
1153        from_object: None,
1154        description: format!("Drop superseded enum \"{}\".\"{}\"", schema, tmp_name),
1155        ddl: Some(format!(
1156            "DROP TYPE IF EXISTS {}.{}",
1157            quote(schema),
1158            quote(&tmp_name)
1159        )),
1160        safety: MigrationSafety::BestEffort,
1161        risk: MigrationRisk::None,
1162        risk_detail: None,
1163    });
1164}
1165
1166// ─── compute_migration_plan ──────────────────────────────────────────────────
1167
1168/// Diff two package configs and produce an ordered list of migration steps.
1169/// This is a pure function — it does not touch the database.
1170/// Pass the result to `execute_migration_plan` after user confirmation.
1171pub fn compute_migration_plan(
1172    old: &FullConfig,
1173    new: &FullConfig,
1174    schema_override: Option<&str>,
1175    _rls_tenant_column: Option<&str>,
1176    dialect: &dyn Dialect,
1177    cross_package_configs: &HashMap<String, FullConfig>,
1178) -> Result<MigrationPlan, AppError> {
1179    validate(new)?;
1180
1181    let default_old_sid = old.schemas.first().map(|s| s.id.as_str()).unwrap_or("");
1182    let default_new_sid = new.schemas.first().map(|s| s.id.as_str()).unwrap_or("");
1183
1184    let old_schemas: HashMap<&str, &SchemaConfig> =
1185        old.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
1186    let new_schemas: HashMap<&str, &SchemaConfig> =
1187        new.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
1188    let old_tables: HashMap<&str, &TableConfig> =
1189        old.tables.iter().map(|t| (t.id.as_str(), t)).collect();
1190    let new_tables: HashMap<&str, &TableConfig> =
1191        new.tables.iter().map(|t| (t.id.as_str(), t)).collect();
1192    let old_columns: HashMap<&str, &ColumnConfig> =
1193        old.columns.iter().map(|c| (c.id.as_str(), c)).collect();
1194    let old_enums: HashMap<&str, &EnumConfig> =
1195        old.enums.iter().map(|e| (e.id.as_str(), e)).collect();
1196    let new_enums: HashMap<&str, &EnumConfig> =
1197        new.enums.iter().map(|e| (e.id.as_str(), e)).collect();
1198    let old_indexes: HashMap<&str, &IndexConfig> =
1199        old.indexes.iter().map(|i| (i.id.as_str(), i)).collect();
1200    let new_indexes: HashMap<&str, &IndexConfig> =
1201        new.indexes.iter().map(|i| (i.id.as_str(), i)).collect();
1202    let old_rels: HashMap<&str, &RelationshipConfig> = old
1203        .relationships
1204        .iter()
1205        .map(|r| (r.id.as_str(), r))
1206        .collect();
1207    let new_rels: HashMap<&str, &RelationshipConfig> = new
1208        .relationships
1209        .iter()
1210        .map(|r| (r.id.as_str(), r))
1211        .collect();
1212
1213    let mut steps: Vec<MigrationStep> = Vec::new();
1214
1215    let schema_name_for = |sid: &str, schemas: &HashMap<&str, &SchemaConfig>| -> String {
1216        schema_override.map(String::from).unwrap_or_else(|| {
1217            schemas
1218                .get(sid)
1219                .map(|s| s.name.clone())
1220                .unwrap_or_else(|| sid.to_string())
1221        })
1222    };
1223
1224    // ── 1. New schemas ───────────────────────────────────────────────────────
1225    if schema_override.is_none() {
1226        for s in &new.schemas {
1227            if !old_schemas.contains_key(s.id.as_str()) {
1228                steps.push(MigrationStep {
1229                    step: 0,
1230                    operation: MigrationOperation::CreateSchema,
1231                    schema: s.name.clone(),
1232                    table: None,
1233                    object: s.name.clone(),
1234                    object_type: "schema".into(),
1235                    from_object: None,
1236                    description: format!("Create schema \"{}\"", s.name),
1237                    ddl: Some(format!("CREATE SCHEMA IF NOT EXISTS {}", quote(&s.name))),
1238                    safety: MigrationSafety::Safe,
1239                    risk: MigrationRisk::None,
1240                    risk_detail: None,
1241                });
1242            }
1243        }
1244    }
1245
1246    // ── 2. Enums ─────────────────────────────────────────────────────────────
1247    for new_enum in &new.enums {
1248        let sid = new_enum.schema_id.as_deref().unwrap_or(default_new_sid);
1249        let schema = schema_name_for(sid, &new_schemas);
1250
1251        if let Some(old_enum) = old_enums.get(new_enum.id.as_str()) {
1252            let old_vals: HashSet<&str> = old_enum.values.iter().map(String::as_str).collect();
1253            let new_vals: HashSet<&str> = new_enum.values.iter().map(String::as_str).collect();
1254            let removed: Vec<&str> = old_enum
1255                .values
1256                .iter()
1257                .map(String::as_str)
1258                .filter(|v| !new_vals.contains(v))
1259                .collect();
1260
1261            if removed.is_empty() {
1262                // Purely additive — append each new value in place (cheap, non-destructive).
1263                for val in new_enum
1264                    .values
1265                    .iter()
1266                    .map(String::as_str)
1267                    .filter(|v| !old_vals.contains(v))
1268                {
1269                    steps.push(MigrationStep {
1270                        step: 0,
1271                        operation: MigrationOperation::AddEnumValue,
1272                        schema: schema.clone(),
1273                        table: None,
1274                        object: format!("{}:{}", new_enum.name, val),
1275                        object_type: "enum_value".into(),
1276                        from_object: None,
1277                        description: format!(
1278                            "Add value '{}' to enum \"{}\".\"{}\"",
1279                            val, schema, new_enum.name
1280                        ),
1281                        ddl: Some(format!(
1282                            "ALTER TYPE {}.{} ADD VALUE IF NOT EXISTS '{}'",
1283                            quote(&schema),
1284                            quote(&new_enum.name),
1285                            val.replace('\'', "''")
1286                        )),
1287                        safety: MigrationSafety::Safe,
1288                        risk: MigrationRisk::None,
1289                        risk_detail: None,
1290                    });
1291                }
1292            } else {
1293                // One or more values removed. PostgreSQL has no DROP VALUE, so rebuild the type and
1294                // recast every dependent column. Added values (if any) are folded into the rebuilt
1295                // value list, so no separate ADD VALUE step is needed.
1296                let dependents = enum_dependent_columns(
1297                    new_enum,
1298                    new,
1299                    &new_tables,
1300                    &new_schemas,
1301                    schema_override,
1302                );
1303                recreate_enum_steps(&mut steps, &schema, new_enum, &removed, &dependents);
1304            }
1305        } else {
1306            let values: Vec<String> = new_enum
1307                .values
1308                .iter()
1309                .map(|v| format!("'{}'", v.replace('\'', "''")))
1310                .collect();
1311            steps.push(MigrationStep {
1312                step: 0,
1313                operation: MigrationOperation::CreateEnum,
1314                schema: schema.clone(),
1315                table: None,
1316                object: new_enum.name.clone(),
1317                object_type: "enum".into(),
1318                from_object: None,
1319                description: format!("Create enum type \"{}\".\"{}\"", schema, new_enum.name),
1320                ddl: Some(format!("CREATE TYPE {}.{} AS ENUM ({})", quote(&schema), quote(&new_enum.name), values.join(", "))),
1321                safety: MigrationSafety::BestEffort,
1322                risk: MigrationRisk::None,
1323                risk_detail: Some("PostgreSQL has no CREATE TYPE IF NOT EXISTS; ignored if the type already exists.".into()),
1324            });
1325        }
1326    }
1327    for old_enum in &old.enums {
1328        if !new_enums.contains_key(old_enum.id.as_str()) {
1329            let sid = old_enum.schema_id.as_deref().unwrap_or(default_old_sid);
1330            let schema = schema_name_for(sid, &old_schemas);
1331            steps.push(MigrationStep {
1332                step: 0,
1333                operation: MigrationOperation::DropEnum,
1334                schema: schema.clone(),
1335                table: None,
1336                object: old_enum.name.clone(),
1337                object_type: "enum".into(),
1338                from_object: None,
1339                description: format!("Enum \"{}\".\"{}\" removed from config", schema, old_enum.name),
1340                ddl: None,
1341                safety: MigrationSafety::WarnOnly,
1342                risk: MigrationRisk::ManualActionRequired,
1343                risk_detail: Some("Enum type NOT dropped from database (data safety). Run DROP TYPE manually if intended.".into()),
1344            });
1345        }
1346    }
1347
1348    // ── 3. New and removed tables ────────────────────────────────────────────
1349    let added_table_ids: HashSet<&str> = new
1350        .tables
1351        .iter()
1352        .filter(|t| !old_tables.contains_key(t.id.as_str()))
1353        .map(|t| t.id.as_str())
1354        .collect();
1355
1356    let cols_by_table: HashMap<&str, Vec<&ColumnConfig>> =
1357        new.columns.iter().fold(HashMap::new(), |mut m, c| {
1358            m.entry(c.table_id.as_str()).or_default().push(c);
1359            m
1360        });
1361
1362    for new_table in &new.tables {
1363        if !added_table_ids.contains(new_table.id.as_str()) {
1364            continue;
1365        }
1366        let sid = new_table.schema_id.as_deref().unwrap_or(default_new_sid);
1367        let schema = schema_name_for(sid, &new_schemas);
1368        let full = format!("{}.{}", quote(&schema), quote(&new_table.name));
1369
1370        let cols = cols_by_table
1371            .get(new_table.id.as_str())
1372            .map(|v| v.as_slice())
1373            .unwrap_or(&[]);
1374        let mut col_defs: Vec<String> = Vec::new();
1375        for c in cols {
1376            let typ = dialect.ddl_type(&parse_canonical(&c.type_));
1377            let mut def = format!("{} {}", quote(&c.name), typ);
1378            if !c.nullable {
1379                def.push_str(" NOT NULL");
1380            }
1381            if let Some(ref d) = c.default {
1382                def.push_str(" DEFAULT ");
1383                match d {
1384                    ColumnDefaultConfig::Literal(s) => def.push_str(s),
1385                    ColumnDefaultConfig::Expression { expression } => def.push_str(expression),
1386                }
1387            }
1388            col_defs.push(def);
1389        }
1390        let cfg_col_names: HashSet<&str> = cols.iter().map(|c| c.name.as_str()).collect();
1391        // Note: compute_migration_plan is a pure DDL-generation function; timestamp strings are
1392        // embedded in the DDL output for display/execution. We use postgres-compatible strings
1393        // here since the plan is always applied to a real DB via execute_migration_plan which
1394        // uses the dialect there. If dialect-awareness is needed here in future, pass dialect in.
1395        for (name, suf) in [
1396            ("created_at", "TIMESTAMPTZ NOT NULL DEFAULT NOW()"),
1397            ("updated_at", "TIMESTAMPTZ NOT NULL DEFAULT NOW()"),
1398            ("archived_at", "TIMESTAMPTZ"),
1399            ("created_by", "TEXT"),
1400            ("updated_by", "TEXT"),
1401        ] {
1402            if !cfg_col_names.contains(name) {
1403                col_defs.push(format!("{} {}", quote(name), suf));
1404            }
1405        }
1406        let pk_cols = match &new_table.primary_key {
1407            PrimaryKeyConfig::Single(s) => vec![quote(s)],
1408            PrimaryKeyConfig::Composite(v) => v.iter().map(|s| quote(s)).collect(),
1409        };
1410        col_defs.push(format!("PRIMARY KEY ({})", pk_cols.join(", ")));
1411        for u in &new_table.unique {
1412            col_defs.push(format!(
1413                "UNIQUE ({})",
1414                u.iter().map(|s| quote(s)).collect::<Vec<_>>().join(", ")
1415            ));
1416        }
1417        for ch in &new_table.check {
1418            col_defs.push(format!(
1419                "CONSTRAINT {} CHECK ({})",
1420                quote(&ch.name),
1421                ch.expression
1422            ));
1423        }
1424
1425        steps.push(MigrationStep {
1426            step: 0,
1427            operation: MigrationOperation::CreateTable,
1428            schema: schema.clone(),
1429            table: Some(new_table.name.clone()),
1430            object: new_table.name.clone(),
1431            object_type: "table".into(),
1432            from_object: None,
1433            description: format!("Create table \"{}\".\"{}\"", schema, new_table.name),
1434            ddl: Some(format!(
1435                "CREATE TABLE IF NOT EXISTS {} (\n  {}\n)",
1436                full,
1437                col_defs.join(",\n  ")
1438            )),
1439            safety: MigrationSafety::Safe,
1440            risk: MigrationRisk::None,
1441            risk_detail: None,
1442        });
1443        if new_table.audit_log {
1444            let audit_ddl = audit_table_ddl(&schema, &new_table.name, cols, dialect);
1445            steps.push(MigrationStep {
1446                step: 0,
1447                operation: MigrationOperation::CreateTable,
1448                schema: schema.clone(),
1449                table: Some(format!("{}_audit", new_table.name)),
1450                object: format!("{}_audit", new_table.name),
1451                object_type: "table".into(),
1452                from_object: None,
1453                description: format!(
1454                    "Create audit table \"{}\".\"{}_audit\"",
1455                    schema, new_table.name
1456                ),
1457                ddl: Some(audit_ddl),
1458                safety: MigrationSafety::Safe,
1459                risk: MigrationRisk::None,
1460                risk_detail: None,
1461            });
1462        }
1463        if new_table.versioning.as_ref().is_some_and(|v| v.enabled) {
1464            let pk_col = match &new_table.primary_key {
1465                PrimaryKeyConfig::Single(s) => s.clone(),
1466                PrimaryKeyConfig::Composite(v) => v[0].clone(),
1467            };
1468            // Split history DDL into CREATE TABLE and index
1469            let history_create = format!(
1470                "CREATE TABLE IF NOT EXISTS {}.{} (\n  {}\n)",
1471                quote(&schema),
1472                quote(&format!("{}_history", new_table.name)),
1473                {
1474                    let full_ddl =
1475                        history_table_ddl(&schema, &new_table.name, &pk_col, cols, dialect);
1476                    full_ddl
1477                        .lines()
1478                        .skip(1) // skip CREATE TABLE line
1479                        .take_while(|l| !l.trim_start().starts_with("-- index:"))
1480                        .collect::<Vec<_>>()
1481                        .join("\n")
1482                        .trim_end_matches(['\n', ',', ')'])
1483                        .to_string()
1484                        + "\n)"
1485                }
1486            );
1487            steps.push(MigrationStep {
1488                step: 0,
1489                operation: MigrationOperation::CreateTable,
1490                schema: schema.clone(),
1491                table: Some(format!("{}_history", new_table.name)),
1492                object: format!("{}_history", new_table.name),
1493                object_type: "table".into(),
1494                from_object: None,
1495                description: format!(
1496                    "Create history table \"{}\".\"{}_history\" (versioning)",
1497                    schema, new_table.name
1498                ),
1499                ddl: Some(history_create),
1500                safety: MigrationSafety::Safe,
1501                risk: MigrationRisk::None,
1502                risk_detail: None,
1503            });
1504            steps.push(MigrationStep {
1505                step: 0,
1506                operation: MigrationOperation::CreateIndex,
1507                schema: schema.clone(),
1508                table: Some(format!("{}_history", new_table.name)),
1509                object: format!("{}_history_{}_idx", new_table.name, pk_col),
1510                object_type: "index".into(),
1511                from_object: None,
1512                description: format!(
1513                    "Create index on history table \"{}\".\"{}\" ({pk_col}, _version DESC)",
1514                    schema, new_table.name
1515                ),
1516                ddl: Some(history_index_ddl(&schema, &new_table.name, &pk_col)),
1517                safety: MigrationSafety::Safe,
1518                risk: MigrationRisk::None,
1519                risk_detail: None,
1520            });
1521        }
1522    }
1523
1524    // Existing tables that gained audit_log or versioning
1525    for new_table in &new.tables {
1526        if added_table_ids.contains(new_table.id.as_str()) {
1527            continue;
1528        }
1529        if let Some(old_table) = old_tables.get(new_table.id.as_str()) {
1530            let sid = new_table.schema_id.as_deref().unwrap_or(default_new_sid);
1531            let schema = schema_name_for(sid, &new_schemas);
1532            let cols = cols_by_table
1533                .get(new_table.id.as_str())
1534                .map(|v| v.as_slice())
1535                .unwrap_or(&[]);
1536
1537            if !old_table.audit_log && new_table.audit_log {
1538                let audit_ddl = audit_table_ddl(&schema, &new_table.name, cols, dialect);
1539                steps.push(MigrationStep {
1540                    step: 0,
1541                    operation: MigrationOperation::CreateTable,
1542                    schema: schema.clone(),
1543                    table: Some(format!("{}_audit", new_table.name)),
1544                    object: format!("{}_audit", new_table.name),
1545                    object_type: "table".into(),
1546                    from_object: None,
1547                    description: format!(
1548                        "Enable audit log: create \"{}\".\"{}_audit\"",
1549                        schema, new_table.name
1550                    ),
1551                    ddl: Some(audit_ddl),
1552                    safety: MigrationSafety::Safe,
1553                    risk: MigrationRisk::None,
1554                    risk_detail: None,
1555                });
1556            }
1557
1558            let old_versioning_enabled = old_table.versioning.as_ref().is_some_and(|v| v.enabled);
1559            let new_versioning_enabled = new_table.versioning.as_ref().is_some_and(|v| v.enabled);
1560            if !old_versioning_enabled && new_versioning_enabled {
1561                let pk_col = match &new_table.primary_key {
1562                    PrimaryKeyConfig::Single(s) => s.clone(),
1563                    PrimaryKeyConfig::Composite(v) => v[0].clone(),
1564                };
1565                let history_ddl =
1566                    history_table_ddl(&schema, &new_table.name, &pk_col, cols, dialect);
1567                let create_only = history_ddl
1568                    .lines()
1569                    .take_while(|l| !l.trim_start().starts_with("-- index:"))
1570                    .collect::<Vec<_>>()
1571                    .join("\n");
1572                steps.push(MigrationStep {
1573                    step: 0,
1574                    operation: MigrationOperation::CreateTable,
1575                    schema: schema.clone(),
1576                    table: Some(format!("{}_history", new_table.name)),
1577                    object: format!("{}_history", new_table.name),
1578                    object_type: "table".into(),
1579                    from_object: None,
1580                    description: format!(
1581                        "Enable versioning: create \"{}\".\"{}_history\"",
1582                        schema, new_table.name
1583                    ),
1584                    ddl: Some(create_only.trim().to_string()),
1585                    safety: MigrationSafety::Safe,
1586                    risk: MigrationRisk::None,
1587                    risk_detail: None,
1588                });
1589                steps.push(MigrationStep {
1590                    step: 0,
1591                    operation: MigrationOperation::CreateIndex,
1592                    schema: schema.clone(),
1593                    table: Some(format!("{}_history", new_table.name)),
1594                    object: format!("{}_history_{}_idx", new_table.name, pk_col),
1595                    object_type: "index".into(),
1596                    from_object: None,
1597                    description: format!(
1598                        "Create history index on \"{}\".\"{}\"",
1599                        schema, new_table.name
1600                    ),
1601                    ddl: Some(history_index_ddl(&schema, &new_table.name, &pk_col)),
1602                    safety: MigrationSafety::Safe,
1603                    risk: MigrationRisk::None,
1604                    risk_detail: None,
1605                });
1606            }
1607        }
1608    }
1609
1610    for old_table in &old.tables {
1611        if !new_tables.contains_key(old_table.id.as_str()) {
1612            let sid = old_table.schema_id.as_deref().unwrap_or(default_old_sid);
1613            let schema = schema_name_for(sid, &old_schemas);
1614            steps.push(MigrationStep {
1615                step: 0,
1616                operation: MigrationOperation::DropTable,
1617                schema: schema.clone(),
1618                table: Some(old_table.name.clone()),
1619                object: old_table.name.clone(),
1620                object_type: "table".into(),
1621                from_object: None,
1622                description: format!("Table \"{}\".\"{}\" removed from config", schema, old_table.name),
1623                ddl: None,
1624                safety: MigrationSafety::WarnOnly,
1625                risk: MigrationRisk::ManualActionRequired,
1626                risk_detail: Some("Table NOT dropped from database (data safety). Run DROP TABLE manually if intended.".into()),
1627            });
1628        }
1629    }
1630
1631    // ── 4. Column changes for existing tables ────────────────────────────────
1632    for new_col in &new.columns {
1633        if added_table_ids.contains(new_col.table_id.as_str()) {
1634            continue;
1635        }
1636        let table = match new_tables.get(new_col.table_id.as_str()) {
1637            Some(t) => t,
1638            None => continue,
1639        };
1640        let sid = table.schema_id.as_deref().unwrap_or(default_new_sid);
1641        let schema = schema_name_for(sid, &new_schemas);
1642        let full = format!("{}.{}", quote(&schema), quote(&table.name));
1643
1644        if let Some(old_col) = old_columns.get(new_col.id.as_str()) {
1645            if old_col.table_id != new_col.table_id {
1646                steps.push(MigrationStep {
1647                    step: 0,
1648                    operation: MigrationOperation::AddColumn,
1649                    schema: schema.clone(),
1650                    table: Some(table.name.clone()),
1651                    object: new_col.name.clone(),
1652                    object_type: "column".into(),
1653                    from_object: None,
1654                    description: format!("Column \"{}\" (id: {}) appears to have moved tables — manual migration required", new_col.name, new_col.id),
1655                    ddl: None,
1656                    safety: MigrationSafety::WarnOnly,
1657                    risk: MigrationRisk::ManualActionRequired,
1658                    risk_detail: Some(format!("Cannot automate column move from table {} to {}.", old_col.table_id, new_col.table_id)),
1659                });
1660                continue;
1661            }
1662
1663            // Rename
1664            if old_col.name != new_col.name {
1665                steps.push(MigrationStep {
1666                    step: 0,
1667                    operation: MigrationOperation::RenameColumn,
1668                    schema: schema.clone(),
1669                    table: Some(table.name.clone()),
1670                    object: new_col.name.clone(),
1671                    object_type: "column".into(),
1672                    from_object: Some(old_col.name.clone()),
1673                    description: format!(
1674                        "Rename column \"{}\" → \"{}\" on \"{}\".\"{}\"",
1675                        old_col.name, new_col.name, schema, table.name
1676                    ),
1677                    ddl: Some(format!(
1678                        "ALTER TABLE {} RENAME COLUMN {} TO {}",
1679                        full,
1680                        quote(&old_col.name),
1681                        quote(&new_col.name)
1682                    )),
1683                    safety: MigrationSafety::Safe,
1684                    risk: MigrationRisk::None,
1685                    risk_detail: None,
1686                });
1687                steps.extend(companion_column_steps(
1688                    &schema,
1689                    table,
1690                    &CompanionColumnOp::Rename {
1691                        old: &old_col.name,
1692                        new: &new_col.name,
1693                    },
1694                    dialect,
1695                ));
1696            }
1697
1698            // Type change
1699            let old_type = dialect.ddl_type(&parse_canonical(&old_col.type_));
1700            let new_type = dialect.ddl_type(&parse_canonical(&new_col.type_));
1701            if old_type.to_uppercase() != new_type.to_uppercase() {
1702                let col_name = &new_col.name;
1703                steps.push(MigrationStep {
1704                    step: 0,
1705                    operation: MigrationOperation::AlterColumnType,
1706                    schema: schema.clone(),
1707                    table: Some(table.name.clone()),
1708                    object: col_name.clone(),
1709                    object_type: "column".into(),
1710                    from_object: None,
1711                    description: format!("Change type of \"{}\".\"{}\".\"{}\": {} → {}", schema, table.name, col_name, old_type, new_type),
1712                    ddl: Some(format!("ALTER TABLE {} ALTER COLUMN {} TYPE {} USING {}::{}", full, quote(col_name), new_type, quote(col_name), new_type)),
1713                    safety: MigrationSafety::BestEffort,
1714                    risk: MigrationRisk::MayFail,
1715                    risk_detail: Some(format!("USING {}::{} cast may fail for incompatible values. Provide a custom USING expression if needed.", col_name, new_type)),
1716                });
1717                steps.extend(companion_column_steps(
1718                    &schema,
1719                    table,
1720                    &CompanionColumnOp::AlterType {
1721                        name: col_name.as_str(),
1722                        ty: &new_type,
1723                    },
1724                    dialect,
1725                ));
1726            }
1727
1728            // Nullability: nullable → NOT NULL
1729            if old_col.nullable && !new_col.nullable {
1730                if let Some(ref d) = new_col.default {
1731                    let default_val = default_str(d);
1732                    // Backfill NULLs first using the configured default
1733                    steps.push(MigrationStep {
1734                        step: 0,
1735                        operation: MigrationOperation::BackfillNulls,
1736                        schema: schema.clone(),
1737                        table: Some(table.name.clone()),
1738                        object: new_col.name.clone(),
1739                        object_type: "column".into(),
1740                        from_object: None,
1741                        description: format!("Backfill NULLs in \"{}\".\"{}\".\"{}\": SET {} = {} WHERE {} IS NULL", schema, table.name, new_col.name, new_col.name, default_val, new_col.name),
1742                        ddl: Some(format!("UPDATE {} SET {} = {} WHERE {} IS NULL", full, quote(&new_col.name), default_val, quote(&new_col.name))),
1743                        safety: MigrationSafety::Safe,
1744                        risk: MigrationRisk::DataWillBeModified,
1745                        risk_detail: Some(format!("Existing NULLs in column \"{}\" will be set to {} before NOT NULL is enforced.", new_col.name, default_val)),
1746                    });
1747                    // Then set NOT NULL — safe because NULLs are gone
1748                    steps.push(MigrationStep {
1749                        step: 0,
1750                        operation: MigrationOperation::SetNotNull,
1751                        schema: schema.clone(),
1752                        table: Some(table.name.clone()),
1753                        object: new_col.name.clone(),
1754                        object_type: "column".into(),
1755                        from_object: None,
1756                        description: format!("Set NOT NULL on \"{}\".\"{}\".\"{}\": NULLs pre-filled with default ({})", schema, table.name, new_col.name, default_val),
1757                        ddl: Some(format!("ALTER TABLE {} ALTER COLUMN {} SET NOT NULL", full, quote(&new_col.name))),
1758                        safety: MigrationSafety::Safe,
1759                        risk: MigrationRisk::None,
1760                        risk_detail: None,
1761                    });
1762                } else {
1763                    // No default — best effort; will fail if NULLs exist
1764                    steps.push(MigrationStep {
1765                        step: 0,
1766                        operation: MigrationOperation::SetNotNull,
1767                        schema: schema.clone(),
1768                        table: Some(table.name.clone()),
1769                        object: new_col.name.clone(),
1770                        object_type: "column".into(),
1771                        from_object: None,
1772                        description: format!("Set NOT NULL on \"{}\".\"{}\".\"{}\": no default configured — will fail if NULLs exist", schema, table.name, new_col.name),
1773                        ddl: Some(format!("ALTER TABLE {} ALTER COLUMN {} SET NOT NULL", full, quote(&new_col.name))),
1774                        safety: MigrationSafety::BestEffort,
1775                        risk: MigrationRisk::ExistingNullsMustBeAbsent,
1776                        risk_detail: Some(format!(
1777                            "No default value configured for column \"{}\". Add a default to the config to enable automatic NULL backfill before enforcing NOT NULL.",
1778                            new_col.name
1779                        )),
1780                    });
1781                }
1782            }
1783
1784            // Nullability: NOT NULL → nullable
1785            if !old_col.nullable && new_col.nullable {
1786                steps.push(MigrationStep {
1787                    step: 0,
1788                    operation: MigrationOperation::DropNotNull,
1789                    schema: schema.clone(),
1790                    table: Some(table.name.clone()),
1791                    object: new_col.name.clone(),
1792                    object_type: "column".into(),
1793                    from_object: None,
1794                    description: format!(
1795                        "Drop NOT NULL on \"{}\".\"{}\".\"{}\": column becomes nullable",
1796                        schema, table.name, new_col.name
1797                    ),
1798                    ddl: Some(format!(
1799                        "ALTER TABLE {} ALTER COLUMN {} DROP NOT NULL",
1800                        full,
1801                        quote(&new_col.name)
1802                    )),
1803                    safety: MigrationSafety::Safe,
1804                    risk: MigrationRisk::None,
1805                    risk_detail: None,
1806                });
1807            }
1808
1809            // Default change
1810            let old_def = old_col.default.as_ref().map(default_str);
1811            let new_def = new_col.default.as_ref().map(default_str);
1812            if old_def != new_def {
1813                match &new_col.default {
1814                    Some(d) => {
1815                        let val = default_str(d);
1816                        steps.push(MigrationStep {
1817                            step: 0,
1818                            operation: MigrationOperation::SetDefault,
1819                            schema: schema.clone(),
1820                            table: Some(table.name.clone()),
1821                            object: new_col.name.clone(),
1822                            object_type: "column".into(),
1823                            from_object: None,
1824                            description: format!(
1825                                "Set DEFAULT {} on \"{}\".\"{}\".\"{}\": was {}",
1826                                val,
1827                                schema,
1828                                table.name,
1829                                new_col.name,
1830                                old_def.as_deref().unwrap_or("none")
1831                            ),
1832                            ddl: Some(format!(
1833                                "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {}",
1834                                full,
1835                                quote(&new_col.name),
1836                                val
1837                            )),
1838                            safety: MigrationSafety::Safe,
1839                            risk: MigrationRisk::None,
1840                            risk_detail: None,
1841                        });
1842                    }
1843                    None => {
1844                        steps.push(MigrationStep {
1845                            step: 0,
1846                            operation: MigrationOperation::DropDefault,
1847                            schema: schema.clone(),
1848                            table: Some(table.name.clone()),
1849                            object: new_col.name.clone(),
1850                            object_type: "column".into(),
1851                            from_object: None,
1852                            description: format!(
1853                                "Drop DEFAULT on \"{}\".\"{}\".\"{}\": was {}",
1854                                schema,
1855                                table.name,
1856                                new_col.name,
1857                                old_def.as_deref().unwrap_or("none")
1858                            ),
1859                            ddl: Some(format!(
1860                                "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT",
1861                                full,
1862                                quote(&new_col.name)
1863                            )),
1864                            safety: MigrationSafety::Safe,
1865                            risk: MigrationRisk::None,
1866                            risk_detail: None,
1867                        });
1868                    }
1869                }
1870            }
1871        } else {
1872            // New column: ADD COLUMN
1873            let new_type = dialect.ddl_type(&parse_canonical(&new_col.type_));
1874            let mut col_def = format!("{} {}", quote(&new_col.name), new_type);
1875            if !new_col.nullable {
1876                col_def.push_str(" NOT NULL");
1877            }
1878            if let Some(ref d) = new_col.default {
1879                col_def.push_str(" DEFAULT ");
1880                match d {
1881                    ColumnDefaultConfig::Literal(s) => col_def.push_str(s),
1882                    ColumnDefaultConfig::Expression { expression } => col_def.push_str(expression),
1883                }
1884            }
1885            steps.push(MigrationStep {
1886                step: 0,
1887                operation: MigrationOperation::AddColumn,
1888                schema: schema.clone(),
1889                table: Some(table.name.clone()),
1890                object: new_col.name.clone(),
1891                object_type: "column".into(),
1892                from_object: None,
1893                description: format!(
1894                    "Add column \"{}\" {} to \"{}\".\"{}\"",
1895                    new_col.name, new_type, schema, table.name
1896                ),
1897                ddl: Some(add_column_ddl(dialect, &full, &col_def)),
1898                safety: MigrationSafety::Safe,
1899                risk: MigrationRisk::None,
1900                risk_detail: None,
1901            });
1902            steps.extend(companion_column_steps(
1903                &schema,
1904                table,
1905                &CompanionColumnOp::Add {
1906                    name: &new_col.name,
1907                    ty: &new_type,
1908                },
1909                dialect,
1910            ));
1911        }
1912    }
1913
1914    // Removed columns (warn only)
1915    for old_col in &old.columns {
1916        if new.columns.iter().any(|c| c.id == old_col.id) {
1917            continue;
1918        }
1919        if !new_tables.contains_key(old_col.table_id.as_str()) {
1920            continue;
1921        }
1922        let table_name = old_tables
1923            .get(old_col.table_id.as_str())
1924            .map(|t| t.name.as_str())
1925            .unwrap_or(&old_col.table_id);
1926        let sid = old_tables
1927            .get(old_col.table_id.as_str())
1928            .and_then(|t| t.schema_id.as_deref())
1929            .unwrap_or(default_old_sid);
1930        let schema = schema_name_for(sid, &old_schemas);
1931        steps.push(MigrationStep {
1932            step: 0,
1933            operation: MigrationOperation::DropColumn,
1934            schema: schema.clone(),
1935            table: Some(table_name.to_string()),
1936            object: old_col.name.clone(),
1937            object_type: "column".into(),
1938            from_object: None,
1939            description: format!("Column \"{}\" removed from config on \"{}\".\"{}\"", old_col.name, schema, table_name),
1940            ddl: None,
1941            safety: MigrationSafety::WarnOnly,
1942            risk: MigrationRisk::ManualActionRequired,
1943            risk_detail: Some("Column NOT dropped from database (data safety). Run ALTER TABLE DROP COLUMN manually if intended.".into()),
1944        });
1945    }
1946
1947    // ── 5. Indexes ───────────────────────────────────────────────────────────
1948    for old_idx in &old.indexes {
1949        if !new_indexes.contains_key(old_idx.id.as_str()) {
1950            let sid = old_idx.schema_id.as_deref().unwrap_or(default_old_sid);
1951            let schema = schema_name_for(sid, &old_schemas);
1952            steps.push(MigrationStep {
1953                step: 0,
1954                operation: MigrationOperation::DropIndex,
1955                schema: schema.clone(),
1956                table: old_tables
1957                    .get(old_idx.table_id.as_str())
1958                    .map(|t| t.name.clone()),
1959                object: old_idx.name.clone(),
1960                object_type: "index".into(),
1961                from_object: None,
1962                description: format!("Drop index \"{}\" in schema \"{}\"", old_idx.name, schema),
1963                ddl: Some(format!(
1964                    "DROP INDEX IF EXISTS {}.{}",
1965                    quote(&schema),
1966                    quote(&old_idx.name)
1967                )),
1968                safety: MigrationSafety::Safe,
1969                risk: MigrationRisk::None,
1970                risk_detail: None,
1971            });
1972        }
1973    }
1974    for new_idx in &new.indexes {
1975        if old_indexes.contains_key(new_idx.id.as_str())
1976            || added_table_ids.contains(new_idx.table_id.as_str())
1977        {
1978            continue;
1979        }
1980        let sid = new_idx.schema_id.as_deref().unwrap_or(default_new_sid);
1981        let schema = match new_schemas.get(sid) {
1982            Some(s) => schema_override.unwrap_or(&s.name).to_string(),
1983            None => continue,
1984        };
1985        let table = match new_tables.get(new_idx.table_id.as_str()) {
1986            Some(t) => t,
1987            None => continue,
1988        };
1989        let full_table = format!("{}.{}", quote(&schema), quote(&table.name));
1990        let mut col_parts: Vec<String> = Vec::new();
1991        for col in &new_idx.columns {
1992            match col {
1993                IndexColumnEntry::Name(n) => col_parts.push(quote(n)),
1994                IndexColumnEntry::Spec {
1995                    name, direction, ..
1996                } => {
1997                    let dir = direction
1998                        .as_deref()
1999                        .map(|d| format!(" {}", d.to_uppercase()))
2000                        .unwrap_or_default();
2001                    col_parts.push(format!("{}{}", quote(name), dir));
2002                }
2003                IndexColumnEntry::Expression { expression } => col_parts.push(expression.clone()),
2004            }
2005        }
2006        let method = new_idx.method.as_deref().unwrap_or("btree");
2007        let unique_kw = if new_idx.unique { "UNIQUE " } else { "" };
2008        let include = if new_idx.include.is_empty() {
2009            String::new()
2010        } else {
2011            format!(
2012                " INCLUDE ({})",
2013                new_idx
2014                    .include
2015                    .iter()
2016                    .map(|s| quote(s))
2017                    .collect::<Vec<_>>()
2018                    .join(", ")
2019            )
2020        };
2021        let where_clause = new_idx
2022            .where_
2023            .as_ref()
2024            .map(|w| format!(" WHERE {}", w))
2025            .unwrap_or_default();
2026        steps.push(MigrationStep {
2027            step: 0,
2028            operation: MigrationOperation::CreateIndex,
2029            schema: schema.clone(),
2030            table: Some(table.name.clone()),
2031            object: new_idx.name.clone(),
2032            object_type: "index".into(),
2033            from_object: None,
2034            description: format!(
2035                "Create {}index \"{}\" on \"{}\".\"{}\"",
2036                if new_idx.unique { "unique " } else { "" },
2037                new_idx.name,
2038                schema,
2039                table.name
2040            ),
2041            ddl: Some(format!(
2042                "CREATE {}INDEX IF NOT EXISTS {} ON {} USING {} ({}){}{}",
2043                unique_kw,
2044                quote(&new_idx.name),
2045                full_table,
2046                method,
2047                col_parts.join(", "),
2048                include,
2049                where_clause
2050            )),
2051            safety: MigrationSafety::Safe,
2052            risk: MigrationRisk::None,
2053            risk_detail: None,
2054        });
2055    }
2056
2057    // ── 6. Foreign keys ──────────────────────────────────────────────────────
2058    for old_rel in &old.relationships {
2059        if !new_rels.contains_key(old_rel.id.as_str()) {
2060            let from_sid_fallback = old_rel.from_schema_id.as_deref().unwrap_or(default_old_sid);
2061            let from_schema = old_schemas
2062                .get(from_sid_fallback)
2063                .map(|s| s.name.as_str())
2064                .unwrap_or(from_sid_fallback);
2065            let from_table = old_tables
2066                .get(old_rel.from_table_id.as_str())
2067                .map(|t| t.name.as_str())
2068                .unwrap_or(&old_rel.from_table_id);
2069            let constraint = old_rel.name.as_deref().unwrap_or(&old_rel.id);
2070            let schema_q = quote(schema_override.unwrap_or(from_schema));
2071            steps.push(MigrationStep {
2072                step: 0,
2073                operation: MigrationOperation::DropForeignKey,
2074                schema: schema_override.unwrap_or(from_schema).to_string(),
2075                table: Some(from_table.to_string()),
2076                object: constraint.to_string(),
2077                object_type: "foreign_key".into(),
2078                from_object: None,
2079                description: format!(
2080                    "Drop FK \"{}\" from \"{}\".\"{}\"",
2081                    constraint,
2082                    schema_override.unwrap_or(from_schema),
2083                    from_table
2084                ),
2085                ddl: Some(format!(
2086                    "ALTER TABLE {}.{} DROP CONSTRAINT IF EXISTS {}",
2087                    schema_q,
2088                    quote(from_table),
2089                    quote(constraint)
2090                )),
2091                safety: MigrationSafety::Safe,
2092                risk: MigrationRisk::None,
2093                risk_detail: None,
2094            });
2095        }
2096    }
2097    for new_rel in &new.relationships {
2098        if old_rels.contains_key(new_rel.id.as_str())
2099            || added_table_ids.contains(new_rel.from_table_id.as_str())
2100            || added_table_ids.contains(new_rel.to_table_id.as_str())
2101        {
2102            continue;
2103        }
2104        let from_sid = new_rel.from_schema_id.as_deref().unwrap_or(default_new_sid);
2105        let from_schema = match new_schemas.get(from_sid) {
2106            Some(s) => s,
2107            None => continue,
2108        };
2109        let from_table = match new_tables.get(new_rel.from_table_id.as_str()) {
2110            Some(t) => t,
2111            None => continue,
2112        };
2113        let from_col = new
2114            .columns
2115            .iter()
2116            .find(|c| c.id == new_rel.from_column_id)
2117            .map(|c| c.name.clone())
2118            .unwrap_or_else(|| new_rel.from_column_id.clone());
2119
2120        // Resolve the target side — cross-package or same-package.
2121        let (to_schema_name, to_table_name, to_col) =
2122            if let Some(pkg_id) = new_rel.to_package_id.as_deref() {
2123                match cross_package_configs.get(pkg_id) {
2124                    Some(foreign) => {
2125                        let foreign_tables: HashMap<_, _> =
2126                            foreign.tables.iter().map(|t| (t.id.as_str(), t)).collect();
2127                        let foreign_schemas: HashMap<_, _> =
2128                            foreign.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
2129                        let foreign_default_sid =
2130                            foreign.schemas.first().map(|s| s.id.as_str()).unwrap_or("");
2131                        let to_sid = new_rel
2132                            .to_schema_id
2133                            .as_deref()
2134                            .unwrap_or(foreign_default_sid);
2135                        let tbl = match foreign_tables.get(new_rel.to_table_id.as_str()) {
2136                            Some(t) => t,
2137                            None => continue,
2138                        };
2139                        let schema = match foreign_schemas.get(to_sid) {
2140                            Some(s) => s,
2141                            None => continue,
2142                        };
2143                        let col = foreign
2144                            .columns
2145                            .iter()
2146                            .find(|c| c.id == new_rel.to_column_id)
2147                            .map(|c| c.name.clone())
2148                            .unwrap_or_else(|| new_rel.to_column_id.clone());
2149                        (schema.name.clone(), tbl.name.clone(), col)
2150                    }
2151                    None => continue,
2152                }
2153            } else {
2154                let to_sid = new_rel.to_schema_id.as_deref().unwrap_or(default_new_sid);
2155                let to_schema = match new_schemas.get(to_sid) {
2156                    Some(s) => s,
2157                    None => continue,
2158                };
2159                let to_table = match new_tables.get(new_rel.to_table_id.as_str()) {
2160                    Some(t) => t,
2161                    None => continue,
2162                };
2163                let col = new
2164                    .columns
2165                    .iter()
2166                    .find(|c| c.id == new_rel.to_column_id)
2167                    .map(|c| c.name.clone())
2168                    .unwrap_or_else(|| new_rel.to_column_id.clone());
2169                (
2170                    schema_override.unwrap_or(&to_schema.name).to_string(),
2171                    to_table.name.clone(),
2172                    col,
2173                )
2174            };
2175
2176        let from_schema_str = schema_override.unwrap_or(&from_schema.name);
2177        let from_q = format!("{}.{}", quote(from_schema_str), quote(&from_table.name));
2178        let to_q = format!("{}.{}", quote(&to_schema_name), quote(&to_table_name));
2179        let constraint = new_rel.name.as_deref().unwrap_or(&new_rel.id);
2180        let on_update = new_rel.on_update.as_deref().unwrap_or("NO ACTION");
2181        let on_delete = new_rel.on_delete.as_deref().unwrap_or("NO ACTION");
2182        steps.push(MigrationStep {
2183            step: 0,
2184            operation: MigrationOperation::AddForeignKey,
2185            schema: from_schema_str.to_string(),
2186            table: Some(from_table.name.clone()),
2187            object: constraint.to_string(),
2188            object_type: "foreign_key".into(),
2189            from_object: None,
2190            description: format!(
2191                "Add FK \"{}\" on \"{}\".\"{}\" → \"{}\".\"{}\"",
2192                constraint, from_schema_str, from_table.name, to_schema_name, to_table_name
2193            ),
2194            ddl: Some(format!(
2195                "ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({}) ON UPDATE {} ON DELETE {}",
2196                from_q, quote(constraint), quote(&from_col), to_q, quote(&to_col), on_update, on_delete
2197            )),
2198            safety: MigrationSafety::BestEffort,
2199            risk: MigrationRisk::None,
2200            risk_detail: Some("PostgreSQL has no ADD CONSTRAINT IF NOT EXISTS; ignored if constraint already exists.".into()),
2201        });
2202    }
2203
2204    // Assign sequential step numbers
2205    for (i, s) in steps.iter_mut().enumerate() {
2206        s.step = i + 1;
2207    }
2208
2209    Ok(MigrationPlan { steps })
2210}
2211
2212// ─── Plan reconciliation against the physical database ───────────────────────
2213
2214/// Whether a plan step still has work to do against a particular database.
2215#[derive(Debug, Clone, PartialEq, Eq)]
2216pub enum StepDecision {
2217    /// Run the step's DDL.
2218    Execute,
2219    /// The step's effect is already present; the payload explains why it was skipped.
2220    Skip(String),
2221}
2222
2223/// Decide whether `step` needs to run against the database described by `snap`.
2224///
2225/// Migration plans are a pure config-vs-config diff, so they assume the database matches the
2226/// *old* config exactly. It often does not: an upgrade may have failed halfway and been retried,
2227/// one plan is broadcast to many tenant databases at different states, and RLS/companion DDL adds
2228/// columns outside the diff. Rather than aborting on `column "x" already exists`, skip the steps
2229/// whose outcome is already true.
2230///
2231/// Negative rules ("the column is missing, so this step cannot run") only fire when the table is
2232/// actually known to the snapshot — an empty or failed introspection therefore skips nothing.
2233pub fn reconcile_step(step: &MigrationStep, snap: &DbSnapshot) -> StepDecision {
2234    if step.ddl.is_none() || !snap.introspected {
2235        return StepDecision::Execute;
2236    }
2237    let schema = step.schema.as_str();
2238    let object = step.object.as_str();
2239    let Some(table) = step.table.as_deref() else {
2240        return StepDecision::Execute;
2241    };
2242    let table_known = snap.has_table(schema, table);
2243    let column = snap.column(schema, table, object);
2244    // Only meaningful for a table we could actually read: an unknown table means the snapshot
2245    // has nothing to say, not that the column is missing.
2246    let column_gone = table_known && column.is_none();
2247    let missing_column = || {
2248        format!(
2249            "column \"{}\" does not exist on \"{}\".\"{}\"",
2250            object, schema, table
2251        )
2252    };
2253
2254    use MigrationOperation as Op;
2255    let reason: Option<String> = match step.operation {
2256        Op::CreateTable if table_known => {
2257            Some(format!("table \"{}\".\"{}\" already exists", schema, table))
2258        }
2259        Op::AddColumn if column.is_some() => Some(format!(
2260            "column \"{}\" already exists on \"{}\".\"{}\"",
2261            object, schema, table
2262        )),
2263        Op::RenameColumn => rename_skip_reason(step, snap, schema, table, table_known),
2264
2265        // A column that is gone cannot be retyped, defaulted or backfilled.
2266        Op::AlterColumnType | Op::BackfillNulls | Op::SetDefault if column_gone => {
2267            Some(missing_column())
2268        }
2269
2270        Op::SetNotNull if column.is_some_and(|f| !f.nullable) => Some(format!(
2271            "column \"{}\".\"{}\".\"{}\" is already NOT NULL",
2272            schema, table, object
2273        )),
2274        Op::DropNotNull if column.is_some_and(|f| f.nullable) => Some(format!(
2275            "column \"{}\".\"{}\".\"{}\" is already nullable",
2276            schema, table, object
2277        )),
2278        Op::DropDefault if column.is_some_and(|f| !f.has_default) => Some(format!(
2279            "column \"{}\".\"{}\".\"{}\" has no DEFAULT",
2280            schema, table, object
2281        )),
2282        Op::SetNotNull | Op::DropNotNull | Op::DropDefault if column_gone => Some(missing_column()),
2283
2284        Op::CreateIndex if snap.indexes_known && snap.has_index(schema, object) => Some(format!(
2285            "index \"{}\".\"{}\" already exists",
2286            schema, object
2287        )),
2288        Op::AddForeignKey
2289            if snap.constraints_known && snap.has_constraint(schema, table, object) =>
2290        {
2291            Some(format!(
2292                "constraint \"{}\" already exists on \"{}\".\"{}\"",
2293                object, schema, table
2294            ))
2295        }
2296
2297        // Drops already carry IF EXISTS, and enum steps are either IF NOT EXISTS or part of a
2298        // rename/recreate sequence whose intermediate state a pre-flight snapshot cannot describe.
2299        _ => None,
2300    };
2301
2302    match reason {
2303        Some(r) => StepDecision::Skip(r),
2304        None => StepDecision::Execute,
2305    }
2306}
2307
2308/// Skip reason for a `RenameColumn` step, or `None` when the rename still has work to do.
2309fn rename_skip_reason(
2310    step: &MigrationStep,
2311    snap: &DbSnapshot,
2312    schema: &str,
2313    table: &str,
2314    table_known: bool,
2315) -> Option<String> {
2316    // Plans saved before `from_object` existed carry no previous name; nothing to check, so let
2317    // the statement run.
2318    let from = step.from_object.as_deref()?;
2319    if !table_known {
2320        return None;
2321    }
2322    let to = step.object.as_str();
2323    match (
2324        snap.has_column(schema, table, from),
2325        snap.has_column(schema, table, to),
2326    ) {
2327        (false, true) => Some(format!(
2328            "column \"{}\".\"{}\".\"{}\" is already named \"{}\"",
2329            schema, table, from, to
2330        )),
2331        (false, false) => Some(format!(
2332            "neither \"{}\" nor \"{}\" exists on \"{}\".\"{}\" — nothing to rename",
2333            from, to, schema, table
2334        )),
2335        _ => None,
2336    }
2337}
2338
2339/// Fold a successfully executed step into `snap` so later steps in the same plan see the state
2340/// the earlier ones produced (e.g. a column added and then made NOT NULL).
2341///
2342/// `CreateTable` is deliberately not recorded: registering a table with no known columns would
2343/// make later column steps look impossible and get them wrongly skipped.
2344fn apply_step_to_snapshot(step: &MigrationStep, snap: &mut DbSnapshot) {
2345    let schema = step.schema.as_str();
2346    let object = step.object.as_str();
2347    let table = step.table.as_deref();
2348    // `from_object` rides in the tuple so the rename arm needs no nested conditional.
2349    match (step.operation.clone(), table, step.from_object.as_deref()) {
2350        (MigrationOperation::AddColumn, Some(t), _) => {
2351            let ddl = step.ddl.as_deref().unwrap_or("").to_uppercase();
2352            snap.add_column(
2353                schema,
2354                t,
2355                object,
2356                ColumnFacts {
2357                    data_type: String::new(),
2358                    nullable: !ddl.contains(" NOT NULL"),
2359                    has_default: ddl.contains(" DEFAULT "),
2360                },
2361            );
2362        }
2363        (MigrationOperation::RenameColumn, Some(t), Some(from)) => {
2364            snap.rename_column(schema, t, from, object)
2365        }
2366        (MigrationOperation::SetNotNull, Some(t), _) => snap.set_nullable(schema, t, object, false),
2367        (MigrationOperation::DropNotNull, Some(t), _) => snap.set_nullable(schema, t, object, true),
2368        (MigrationOperation::SetDefault, Some(t), _) => {
2369            snap.set_has_default(schema, t, object, true)
2370        }
2371        (MigrationOperation::DropDefault, Some(t), _) => {
2372            snap.set_has_default(schema, t, object, false)
2373        }
2374        (MigrationOperation::CreateIndex, _, _) => snap.add_index(schema, object),
2375        (MigrationOperation::DropIndex, _, _) => snap.remove_index(schema, object),
2376        (MigrationOperation::AddForeignKey, Some(t), _) => snap.add_constraint(schema, t, object),
2377        (MigrationOperation::DropForeignKey, Some(t), _) => {
2378            snap.remove_constraint(schema, t, object)
2379        }
2380        _ => {}
2381    }
2382}
2383
2384/// Recognise "the object I tried to create already exists" errors.
2385///
2386/// Backstop for whatever pre-flight introspection could not see: a schema it had no privileges
2387/// on, an object created by a concurrent migration, or a dialect that reports no index and
2388/// constraint catalogs.
2389fn duplicate_object_reason(dialect: &dyn Dialect, e: &sqlx::Error) -> Option<String> {
2390    let dbe = e.as_database_error()?;
2391    let duplicate_code = dbe
2392        .code()
2393        .is_some_and(|code| dialect.is_duplicate_object_code(code.as_ref()));
2394    if duplicate_code {
2395        return Some(format!(
2396            "object already exists (SQLSTATE {}): {}",
2397            dbe.code().unwrap_or_default(),
2398            dbe.message()
2399        ));
2400    }
2401    let msg = dbe.message().to_ascii_lowercase();
2402    if msg.contains("already exists")
2403        || msg.contains("duplicate column name")
2404        || msg.contains("duplicate key name")
2405    {
2406        return Some(format!("object already exists: {}", dbe.message()));
2407    }
2408    None
2409}
2410
2411// ─── execute_migration_plan ──────────────────────────────────────────────────
2412
2413/// Identifiers of the migration being executed, threaded into every audit row.
2414struct AuditContext<'a> {
2415    migration_plan_id: &'a str,
2416    package_id: &'a str,
2417    tenant_id: &'a str,
2418    from_version: Option<&'a str>,
2419    to_version: &'a str,
2420}
2421
2422async fn audit_step(
2423    config_pool: &Pool,
2424    ctx: &AuditContext<'_>,
2425    step: &MigrationStep,
2426    status: &str,
2427    error_message: Option<&str>,
2428) {
2429    let _ = crate::store::insert_migration_audit(
2430        config_pool,
2431        ctx.migration_plan_id,
2432        ctx.package_id,
2433        ctx.tenant_id,
2434        ctx.from_version,
2435        ctx.to_version,
2436        step.step as i32,
2437        &step.operation.to_string(),
2438        &step.schema,
2439        step.table.as_deref(),
2440        &step.object,
2441        &step.object_type,
2442        &step.description,
2443        step.ddl.as_deref(),
2444        &format!("{:?}", step.safety),
2445        &format!("{:?}", step.risk),
2446        status,
2447        error_message,
2448    )
2449    .await;
2450}
2451
2452/// Execute a pre-computed `MigrationPlan` against the tenant database.
2453///
2454/// The plan is reconciled against the database's actual state first (see [`reconcile_step`]), so
2455/// steps whose effect is already present are skipped instead of failing the whole migration.
2456/// Writes per-step audit records to the config (architect) database.
2457/// Returns counts and any warning messages collected from best-effort failures.
2458#[allow(clippy::too_many_arguments)]
2459pub async fn execute_migration_plan(
2460    migration_pool: &Pool,
2461    config_pool: &Pool,
2462    plan: &MigrationPlan,
2463    migration_plan_id: &str,
2464    package_id: &str,
2465    tenant_id: &str,
2466    from_version: Option<&str>,
2467    to_version: &str,
2468    dialect: &dyn Dialect,
2469) -> Result<MigrationExecutionResult, AppError> {
2470    let mut applied = 0usize;
2471    let mut warned = 0usize;
2472    let mut skipped = 0usize;
2473    let mut warnings: Vec<String> = Vec::new();
2474    let mut skips: Vec<String> = Vec::new();
2475
2476    let ctx = AuditContext {
2477        migration_plan_id,
2478        package_id,
2479        tenant_id,
2480        from_version,
2481        to_version,
2482    };
2483
2484    // Pre-flight: what does this database actually look like? Best-effort — on failure the
2485    // snapshot stays empty, nothing is skipped, and execution behaves exactly as before.
2486    let schemas: Vec<String> = {
2487        let mut seen: Vec<String> = Vec::new();
2488        for step in &plan.steps {
2489            if step.ddl.is_some() && !seen.iter().any(|s| s == &step.schema) {
2490                seen.push(step.schema.clone());
2491            }
2492        }
2493        seen
2494    };
2495    let mut snapshot = crate::db::introspect(migration_pool, dialect, &schemas).await;
2496
2497    for step in &plan.steps {
2498        let op = step.operation.to_string();
2499
2500        match step.safety {
2501            MigrationSafety::WarnOnly => {
2502                let msg = step
2503                    .risk_detail
2504                    .clone()
2505                    .unwrap_or_else(|| step.description.clone());
2506                tracing::warn!(step = step.step, %op, "migration plan warning (no DDL)");
2507                warnings.push(format!("[Step {}] {}", step.step, msg));
2508                audit_step(config_pool, &ctx, step, "skipped", None).await;
2509                warned += 1;
2510            }
2511            MigrationSafety::Safe | MigrationSafety::BestEffort => {
2512                let Some(ref sql) = step.ddl else {
2513                    continue;
2514                };
2515
2516                if let StepDecision::Skip(reason) = reconcile_step(step, &snapshot) {
2517                    tracing::info!(step = step.step, %op, %reason, "migration step already satisfied — skipping");
2518                    skips.push(format!(
2519                        "[Step {}] {} — {}",
2520                        step.step, step.description, reason
2521                    ));
2522                    audit_step(config_pool, &ctx, step, "skipped_exists", Some(&reason)).await;
2523                    skipped += 1;
2524                    continue;
2525                }
2526
2527                tracing::info!(step = step.step, %op, %sql, "executing migration step");
2528                match sqlx::query(sql).execute(migration_pool).await {
2529                    Ok(_) => {
2530                        apply_step_to_snapshot(step, &mut snapshot);
2531                        audit_step(config_pool, &ctx, step, "applied", None).await;
2532                        applied += 1;
2533                    }
2534                    Err(e) => {
2535                        // The object already exists: the step's effect is in place, whatever the
2536                        // config diff believed. Record it and carry on.
2537                        if let Some(reason) = duplicate_object_reason(dialect, &e) {
2538                            tracing::info!(step = step.step, %op, %reason, "migration step already satisfied — skipping");
2539                            skips.push(format!(
2540                                "[Step {}] {} — {}",
2541                                step.step, step.description, reason
2542                            ));
2543                            audit_step(config_pool, &ctx, step, "skipped_exists", Some(&reason))
2544                                .await;
2545                            apply_step_to_snapshot(step, &mut snapshot);
2546                            skipped += 1;
2547                            continue;
2548                        }
2549
2550                        let err_str = e.to_string();
2551                        if matches!(step.safety, MigrationSafety::BestEffort) {
2552                            tracing::warn!(step = step.step, %op, error = %e, "migration step failed (best-effort, continuing)");
2553                            warnings.push(format!(
2554                                "[Step {}] {} — Error: {}",
2555                                step.step, step.description, err_str
2556                            ));
2557                            audit_step(config_pool, &ctx, step, "warned", Some(&err_str)).await;
2558                            warned += 1;
2559                        } else {
2560                            audit_step(config_pool, &ctx, step, "failed", Some(&err_str)).await;
2561                            return Err(AppError::Db(e));
2562                        }
2563                    }
2564                }
2565            }
2566        }
2567    }
2568
2569    Ok(MigrationExecutionResult {
2570        applied,
2571        warned,
2572        skipped,
2573        warnings,
2574        skips,
2575    })
2576}
2577
2578/// Build CREATE TABLE DDL for the `{table}_history` companion table used by row versioning.
2579/// All source columns are replicated with their types but as nullable and without any constraints
2580/// (no NOT NULL, no UNIQUE, no FK, no CHECK). Five versioning metadata columns are prepended.
2581pub fn history_table_ddl(
2582    schema_name: &str,
2583    table_name: &str,
2584    pk_col: &str,
2585    source_cols: &[&ColumnConfig],
2586    dialect: &dyn Dialect,
2587) -> String {
2588    let history_name = format!("{}_history", table_name);
2589    let history_full = format!("{}.{}", quote(schema_name), quote(&history_name));
2590
2591    let mut col_defs: Vec<String> = Vec::new();
2592    col_defs.push(format!(
2593        "{} {} NOT NULL DEFAULT {}",
2594        quote("_history_id"),
2595        "UUID",
2596        dialect.uuid_default_expr()
2597    ));
2598    col_defs.push(format!("{} BIGINT NOT NULL", quote("_version")));
2599    col_defs.push(format!("{} TEXT NOT NULL", quote("_operation")));
2600    col_defs.push(format!(
2601        "{} {} NOT NULL DEFAULT {}",
2602        quote("_recorded_at"),
2603        dialect.audit_timestamp_type(),
2604        dialect.now_fn()
2605    ));
2606    col_defs.push(format!(
2607        "{} {}",
2608        quote("_valid_from"),
2609        dialect.audit_timestamp_type()
2610    ));
2611    col_defs.push(format!(
2612        "{} {}",
2613        quote("_valid_to"),
2614        dialect.audit_timestamp_type()
2615    ));
2616
2617    let config_col_names: HashSet<&str> = source_cols.iter().map(|c| c.name.as_str()).collect();
2618    for c in source_cols {
2619        let typ = dialect.ddl_type(&parse_canonical(&c.type_));
2620        col_defs.push(format!("{} {}", quote(&c.name), typ));
2621    }
2622    let audit_ts = dialect.audit_timestamp_type();
2623    for (name, typ) in [
2624        ("created_at", audit_ts),
2625        ("updated_at", audit_ts),
2626        ("archived_at", audit_ts),
2627        ("created_by", "TEXT"),
2628        ("updated_by", "TEXT"),
2629    ] {
2630        if !config_col_names.contains(name) {
2631            col_defs.push(format!("{} {}", quote(name), typ));
2632        }
2633    }
2634    col_defs.push(format!("PRIMARY KEY ({})", quote("_history_id")));
2635
2636    let history_full_quoted = format!("{}.{}", quote(schema_name), quote(&history_name));
2637    let idx_sql = format!(
2638        "-- index: CREATE INDEX IF NOT EXISTS {} ON {} ({}, {})",
2639        quote(&format!("{}_history_{}_idx", table_name, pk_col)),
2640        history_full_quoted,
2641        quote(pk_col),
2642        quote("_version")
2643    );
2644
2645    format!(
2646        "CREATE TABLE IF NOT EXISTS {} (\n  {}\n)\n{}",
2647        history_full,
2648        col_defs.join(",\n  "),
2649        idx_sql
2650    )
2651}
2652
2653/// Build just the index DDL for the `{table}_history` table (separate from CREATE TABLE).
2654fn history_index_ddl(schema_name: &str, table_name: &str, pk_col: &str) -> String {
2655    format!(
2656        "CREATE INDEX IF NOT EXISTS {} ON {}.{} ({}, {} DESC)",
2657        quote(&format!("{}_history_{}_idx", table_name, pk_col)),
2658        quote(schema_name),
2659        quote(&format!("{}_history", table_name)),
2660        quote(pk_col),
2661        quote("_version")
2662    )
2663}
2664
2665/// A structural column change that must be mirrored onto companion (`_audit`/`_history`) tables.
2666enum CompanionColumnOp<'a> {
2667    /// A new column was added to the source table.
2668    Add { name: &'a str, ty: &'a str },
2669    /// A source column was renamed.
2670    Rename { old: &'a str, new: &'a str },
2671    /// A source column's type changed.
2672    AlterType { name: &'a str, ty: &'a str },
2673}
2674
2675/// Companion-table suffixes that are enabled for a table (`audit`, `history`).
2676fn enabled_companion_suffixes(table: &TableConfig) -> Vec<&'static str> {
2677    let mut suffixes = Vec::new();
2678    if table.audit_log {
2679        suffixes.push("audit");
2680    }
2681    if table.versioning.as_ref().is_some_and(|v| v.enabled) {
2682        suffixes.push("history");
2683    }
2684    suffixes
2685}
2686
2687/// Generate ALTER steps that keep the `{table}_audit` / `{table}_history` companion tables in
2688/// schema-sync with their source table when a column is added, renamed, or retyped.
2689///
2690/// Companion tables replicate source columns as **nullable with no constraints**, so only
2691/// structural changes propagate here. Nullability and default changes on the source column are
2692/// intentionally NOT mirrored — companion rows are historical snapshots that must stay nullable.
2693fn companion_column_steps(
2694    schema: &str,
2695    table: &TableConfig,
2696    op: &CompanionColumnOp<'_>,
2697    dialect: &dyn Dialect,
2698) -> Vec<MigrationStep> {
2699    let mut steps = Vec::new();
2700    for suffix in enabled_companion_suffixes(table) {
2701        let companion = format!("{}_{}", table.name, suffix);
2702        let full = format!("{}.{}", quote(schema), quote(&companion));
2703        let (operation, object, from_object, ddl, description, safety, risk, risk_detail) = match op
2704        {
2705            CompanionColumnOp::Add { name, ty } => (
2706                MigrationOperation::AddColumn,
2707                name.to_string(),
2708                None,
2709                // The IF NOT EXISTS guard (where supported) covers collisions with synthetic
2710                // columns the companion table may already carry (e.g. created_at/updated_by).
2711                add_column_ddl(dialect, &full, &format!("{} {}", quote(name), ty)),
2712                format!(
2713                    "Sync {} table: add column \"{}\" to \"{}\".\"{}\"",
2714                    suffix, name, schema, companion
2715                ),
2716                MigrationSafety::Safe,
2717                MigrationRisk::None,
2718                None,
2719            ),
2720            CompanionColumnOp::Rename { old, new } => (
2721                MigrationOperation::RenameColumn,
2722                new.to_string(),
2723                Some(old.to_string()),
2724                format!(
2725                    "ALTER TABLE {} RENAME COLUMN {} TO {}",
2726                    full,
2727                    quote(old),
2728                    quote(new)
2729                ),
2730                format!(
2731                    "Sync {} table: rename column \"{}\" → \"{}\" on \"{}\".\"{}\"",
2732                    suffix, old, new, schema, companion
2733                ),
2734                MigrationSafety::Safe,
2735                MigrationRisk::None,
2736                None,
2737            ),
2738            CompanionColumnOp::AlterType { name, ty } => (
2739                MigrationOperation::AlterColumnType,
2740                name.to_string(),
2741                None,
2742                format!(
2743                    "ALTER TABLE {} ALTER COLUMN {} TYPE {} USING {}::{}",
2744                    full,
2745                    quote(name),
2746                    ty,
2747                    quote(name),
2748                    ty
2749                ),
2750                format!(
2751                    "Sync {} table: change type of \"{}\".\"{}\".\"{}\" → {}",
2752                    suffix, schema, companion, name, ty
2753                ),
2754                MigrationSafety::BestEffort,
2755                MigrationRisk::MayFail,
2756                Some(format!(
2757                    "USING {}::{} cast may fail for incompatible values in the {} table.",
2758                    name, ty, suffix
2759                )),
2760            ),
2761        };
2762        steps.push(MigrationStep {
2763            step: 0,
2764            operation,
2765            schema: schema.to_string(),
2766            table: Some(companion.clone()),
2767            object,
2768            object_type: "column".into(),
2769            from_object,
2770            description,
2771            ddl: Some(ddl),
2772            safety,
2773            risk,
2774            risk_detail,
2775        });
2776    }
2777    steps
2778}
2779
2780/// Build CREATE TABLE DDL for the `{table}_audit` companion table.
2781/// All source columns are replicated as nullable with no constraints, plus five audit metadata
2782/// columns prepended: audit_id (PK), audit_action, audit_at, audit_by, changed_fields.
2783fn audit_table_ddl(
2784    schema_name: &str,
2785    table_name: &str,
2786    source_cols: &[&ColumnConfig],
2787    dialect: &dyn Dialect,
2788) -> String {
2789    let audit_name = format!("{}_audit", table_name);
2790    let audit_full = format!("{}.{}", quote(schema_name), quote(&audit_name));
2791
2792    let mut col_defs: Vec<String> = Vec::new();
2793    col_defs.push(format!(
2794        "{} {} NOT NULL DEFAULT {}",
2795        quote("audit_id"),
2796        "UUID",
2797        dialect.uuid_default_expr()
2798    ));
2799    col_defs.push(format!("{} TEXT NOT NULL", quote("audit_action")));
2800    col_defs.push(format!(
2801        "{} {} NOT NULL DEFAULT {}",
2802        quote("audit_at"),
2803        dialect.audit_timestamp_type(),
2804        dialect.now_fn()
2805    ));
2806    col_defs.push(format!("{} TEXT", quote("audit_by")));
2807    col_defs.push(format!(
2808        "{} {}",
2809        quote("changed_fields"),
2810        dialect.sys_json_type()
2811    ));
2812
2813    let config_col_names: HashSet<&str> = source_cols.iter().map(|c| c.name.as_str()).collect();
2814    for c in source_cols {
2815        let typ = dialect.ddl_type(&parse_canonical(&c.type_));
2816        col_defs.push(format!("{} {}", quote(&c.name), typ));
2817    }
2818    let audit_ts = dialect.audit_timestamp_type();
2819    for (name, typ) in [
2820        ("created_at", audit_ts),
2821        ("updated_at", audit_ts),
2822        ("archived_at", audit_ts),
2823        ("created_by", "TEXT"),
2824        ("updated_by", "TEXT"),
2825    ] {
2826        if !config_col_names.contains(name) {
2827            col_defs.push(format!("{} {}", quote(name), typ));
2828        }
2829    }
2830    col_defs.push(format!("PRIMARY KEY ({})", quote("audit_id")));
2831
2832    format!(
2833        "CREATE TABLE IF NOT EXISTS {} (\n  {}\n)",
2834        audit_full,
2835        col_defs.join(",\n  ")
2836    )
2837}
2838
2839#[cfg(test)]
2840mod enum_recreate_tests {
2841    use super::*;
2842
2843    fn schema(id: &str, name: &str) -> SchemaConfig {
2844        SchemaConfig {
2845            id: id.into(),
2846            name: name.into(),
2847            comment: None,
2848        }
2849    }
2850
2851    fn table(id: &str, name: &str, schema_id: &str) -> TableConfig {
2852        TableConfig {
2853            id: id.into(),
2854            schema_id: Some(schema_id.into()),
2855            name: name.into(),
2856            comment: None,
2857            primary_key: PrimaryKeyConfig::Single("id".into()),
2858            unique: vec![],
2859            check: vec![],
2860            audit_log: false,
2861            versioning: None,
2862            global: false,
2863        }
2864    }
2865
2866    fn col(id: &str, table_id: &str, name: &str, ty: &str, default: Option<&str>) -> ColumnConfig {
2867        ColumnConfig {
2868            id: id.into(),
2869            table_id: table_id.into(),
2870            name: name.into(),
2871            type_: ColumnTypeConfig::Simple(ty.into()),
2872            nullable: true,
2873            default: default.map(|d| ColumnDefaultConfig::Literal(d.into())),
2874            comment: None,
2875            asset: None,
2876            extensible: false,
2877        }
2878    }
2879
2880    fn enum_cfg(id: &str, name: &str, schema_id: &str, values: &[&str]) -> EnumConfig {
2881        EnumConfig {
2882            id: id.into(),
2883            schema_id: Some(schema_id.into()),
2884            name: name.into(),
2885            values: values.iter().map(|s| s.to_string()).collect(),
2886            comment: None,
2887        }
2888    }
2889
2890    fn ddls(steps: &[MigrationStep]) -> Vec<String> {
2891        steps.iter().filter_map(|s| s.ddl.clone()).collect()
2892    }
2893
2894    #[test]
2895    fn finds_scalar_and_array_dependent_columns() {
2896        let mut cfg = FullConfig::default();
2897        cfg.schemas = vec![schema("s1", "app")];
2898        cfg.tables = vec![table("t_orders", "orders", "s1")];
2899        cfg.columns = vec![
2900            col(
2901                "c1",
2902                "t_orders",
2903                "status",
2904                "order_status",
2905                Some("'pending'"),
2906            ),
2907            col("c2", "t_orders", "tags", "order_status[]", None),
2908            col("c3", "t_orders", "name", "text", None), // not an enum
2909        ];
2910        let e = enum_cfg("e1", "order_status", "s1", &["pending", "shipped"]);
2911        let new_tables: HashMap<&str, &TableConfig> =
2912            cfg.tables.iter().map(|t| (t.id.as_str(), t)).collect();
2913        let new_schemas: HashMap<&str, &SchemaConfig> =
2914            cfg.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
2915
2916        let deps = enum_dependent_columns(&e, &cfg, &new_tables, &new_schemas, None);
2917        assert_eq!(deps.len(), 2);
2918        let scalar = deps.iter().find(|d| d.column == "status").unwrap();
2919        assert_eq!(scalar.schema, "app");
2920        assert_eq!(scalar.table, "orders");
2921        assert!(!scalar.is_array);
2922        assert_eq!(scalar.default.as_deref(), Some("'pending'"));
2923        let arr = deps.iter().find(|d| d.column == "tags").unwrap();
2924        assert!(arr.is_array);
2925        assert!(arr.default.is_none());
2926    }
2927
2928    #[test]
2929    fn recreate_sequence_emits_rename_create_recast_drop() {
2930        let e = enum_cfg("e1", "order_status", "s1", &["pending", "shipped"]);
2931        let deps = vec![
2932            EnumColumnRef {
2933                schema: "app".into(),
2934                table: "orders".into(),
2935                column: "status".into(),
2936                default: Some("'pending'".into()),
2937                is_array: false,
2938            },
2939            EnumColumnRef {
2940                schema: "app".into(),
2941                table: "orders".into(),
2942                column: "tags".into(),
2943                default: None,
2944                is_array: true,
2945            },
2946        ];
2947        let mut steps = Vec::new();
2948        recreate_enum_steps(&mut steps, "app", &e, &["cancelled"], &deps);
2949        let sql = ddls(&steps);
2950
2951        // Leading informational step carries no DDL.
2952        assert!(matches!(steps[0].safety, MigrationSafety::WarnOnly));
2953        assert!(steps[0].ddl.is_none());
2954
2955        // Rename → create → (drop default, recast, set default) → recast array → drop old.
2956        assert_eq!(
2957            sql[0],
2958            r#"ALTER TYPE "app"."order_status" RENAME TO "order_status__arch_old""#
2959        );
2960        assert_eq!(
2961            sql[1],
2962            r#"CREATE TYPE "app"."order_status" AS ENUM ('pending', 'shipped')"#
2963        );
2964        assert_eq!(
2965            sql[2],
2966            r#"ALTER TABLE "app"."orders" ALTER COLUMN "status" DROP DEFAULT"#
2967        );
2968        assert_eq!(
2969            sql[3],
2970            r#"ALTER TABLE "app"."orders" ALTER COLUMN "status" TYPE "app"."order_status" USING "status"::text::"app"."order_status""#
2971        );
2972        assert_eq!(
2973            sql[4],
2974            r#"ALTER TABLE "app"."orders" ALTER COLUMN "status" SET DEFAULT 'pending'"#
2975        );
2976        // Array column: no default, single recast with array casts.
2977        assert_eq!(
2978            sql[5],
2979            r#"ALTER TABLE "app"."orders" ALTER COLUMN "tags" TYPE "app"."order_status"[] USING "tags"::text[]::"app"."order_status"[]"#
2980        );
2981        assert_eq!(
2982            *sql.last().unwrap(),
2983            r#"DROP TYPE IF EXISTS "app"."order_status__arch_old""#
2984        );
2985    }
2986}
2987
2988#[cfg(all(test, feature = "sqlite"))]
2989mod companion_sync_tests {
2990    use super::*;
2991    use crate::db::sqlite::SqliteDialect;
2992
2993    fn schema(id: &str, name: &str) -> SchemaConfig {
2994        SchemaConfig {
2995            id: id.into(),
2996            name: name.into(),
2997            comment: None,
2998        }
2999    }
3000
3001    fn table(id: &str, name: &str, audit: bool, versioning: bool) -> TableConfig {
3002        TableConfig {
3003            id: id.into(),
3004            schema_id: Some("s1".into()),
3005            name: name.into(),
3006            comment: None,
3007            primary_key: PrimaryKeyConfig::Single("id".into()),
3008            unique: vec![],
3009            check: vec![],
3010            audit_log: audit,
3011            versioning: versioning.then(|| VersioningConfig {
3012                enabled: true,
3013                keep_versions: None,
3014            }),
3015            global: false,
3016        }
3017    }
3018
3019    fn col(id: &str, name: &str, ty: &str) -> ColumnConfig {
3020        ColumnConfig {
3021            id: id.into(),
3022            table_id: "t1".into(),
3023            name: name.into(),
3024            type_: ColumnTypeConfig::Simple(ty.into()),
3025            nullable: true,
3026            default: None,
3027            comment: None,
3028            asset: None,
3029            extensible: false,
3030        }
3031    }
3032
3033    fn base(audit: bool, versioning: bool) -> FullConfig {
3034        let mut cfg = FullConfig::default();
3035        cfg.schemas = vec![schema("s1", "app")];
3036        cfg.tables = vec![table("t1", "orders", audit, versioning)];
3037        cfg.columns = vec![col("c0", "id", "uuid"), col("c1", "status", "text")];
3038        cfg
3039    }
3040
3041    fn plan(old: &FullConfig, new: &FullConfig) -> Vec<String> {
3042        let dialect = SqliteDialect;
3043        compute_migration_plan(old, new, None, None, &dialect, &HashMap::new())
3044            .unwrap()
3045            .steps
3046            .into_iter()
3047            .filter_map(|s| s.ddl)
3048            .collect()
3049    }
3050
3051    #[test]
3052    fn add_column_syncs_audit_and_history() {
3053        let old = base(true, true);
3054        let mut new = base(true, true);
3055        new.columns.push(col("c2", "note", "text"));
3056
3057        // SQLite has no ADD COLUMN IF NOT EXISTS, so the guard must not appear here.
3058        let sql = plan(&old, &new);
3059        assert!(sql
3060            .iter()
3061            .any(|s| s == r#"ALTER TABLE "app"."orders" ADD COLUMN "note" TEXT"#));
3062        assert!(sql
3063            .iter()
3064            .any(|s| s == r#"ALTER TABLE "app"."orders_audit" ADD COLUMN "note" TEXT"#));
3065        assert!(sql
3066            .iter()
3067            .any(|s| s == r#"ALTER TABLE "app"."orders_history" ADD COLUMN "note" TEXT"#));
3068    }
3069
3070    #[test]
3071    fn rename_column_syncs_companions() {
3072        let old = base(true, true);
3073        let mut new = base(true, true);
3074        new.columns[1].name = "state".into();
3075
3076        let sql = plan(&old, &new);
3077        assert!(sql
3078            .iter()
3079            .any(|s| s == r#"ALTER TABLE "app"."orders_audit" RENAME COLUMN "status" TO "state""#));
3080        assert!(sql.iter().any(
3081            |s| s == r#"ALTER TABLE "app"."orders_history" RENAME COLUMN "status" TO "state""#
3082        ));
3083    }
3084
3085    #[test]
3086    fn alter_type_syncs_companions() {
3087        let old = base(true, false);
3088        let mut new = base(true, false);
3089        new.columns[1].type_ = ColumnTypeConfig::Simple("integer".into());
3090
3091        let sql = plan(&old, &new);
3092        assert!(sql.iter().any(|s| s
3093            == r#"ALTER TABLE "app"."orders_audit" ALTER COLUMN "status" TYPE INTEGER USING "status"::INTEGER"#));
3094        // versioning disabled → no history sync
3095        assert!(!sql.iter().any(|s| s.contains("orders_history")));
3096    }
3097
3098    #[test]
3099    fn no_companion_steps_when_features_disabled() {
3100        let old = base(false, false);
3101        let mut new = base(false, false);
3102        new.columns.push(col("c2", "note", "text"));
3103
3104        let sql = plan(&old, &new);
3105        assert!(sql
3106            .iter()
3107            .any(|s| s == r#"ALTER TABLE "app"."orders" ADD COLUMN "note" TEXT"#));
3108        assert!(!sql.iter().any(|s| s.contains("orders_audit")));
3109        assert!(!sql.iter().any(|s| s.contains("orders_history")));
3110    }
3111
3112    #[test]
3113    fn nullability_change_does_not_touch_companions() {
3114        let old = base(true, true);
3115        let mut new = base(true, true);
3116        new.columns[1].nullable = false;
3117
3118        let sql = plan(&old, &new);
3119        // Main table gets SET NOT NULL, companions are untouched (snapshots stay nullable).
3120        assert!(sql.iter().any(|s| s.contains("SET NOT NULL")));
3121        assert!(!sql.iter().any(|s| s.contains("orders_audit")));
3122        assert!(!sql.iter().any(|s| s.contains("orders_history")));
3123    }
3124}
3125
3126#[cfg(test)]
3127mod reconcile_tests {
3128    use super::*;
3129
3130    fn snapshot() -> DbSnapshot {
3131        let mut snap = DbSnapshot::default();
3132        snap.introspected = true;
3133        snap.indexes_known = true;
3134        snap.constraints_known = true;
3135        snap
3136    }
3137
3138    fn facts(nullable: bool, has_default: bool) -> ColumnFacts {
3139        ColumnFacts {
3140            data_type: "text".into(),
3141            nullable,
3142            has_default,
3143        }
3144    }
3145
3146    fn step(op: MigrationOperation, table: Option<&str>, object: &str) -> MigrationStep {
3147        MigrationStep {
3148            step: 1,
3149            operation: op,
3150            schema: "app".into(),
3151            table: table.map(String::from),
3152            object: object.into(),
3153            object_type: "column".into(),
3154            from_object: None,
3155            description: "test step".into(),
3156            ddl: Some("SELECT 1".into()),
3157            safety: MigrationSafety::Safe,
3158            risk: MigrationRisk::None,
3159            risk_detail: None,
3160        }
3161    }
3162
3163    fn skipped(d: StepDecision) -> bool {
3164        matches!(d, StepDecision::Skip(_))
3165    }
3166
3167    #[test]
3168    fn add_column_is_skipped_when_the_column_already_exists() {
3169        // The regression this whole path exists for: an upgrade plan that adds a column the
3170        // physical table already has must not fail the migration.
3171        let mut snap = snapshot();
3172        snap.add_column("app", "transport_units", "project_id", facts(true, false));
3173
3174        let s = step(
3175            MigrationOperation::AddColumn,
3176            Some("transport_units"),
3177            "project_id",
3178        );
3179        assert!(skipped(reconcile_step(&s, &snap)));
3180    }
3181
3182    #[test]
3183    fn add_column_runs_when_the_column_is_absent() {
3184        let mut snap = snapshot();
3185        snap.add_column("app", "transport_units", "id", facts(false, false));
3186
3187        let s = step(
3188            MigrationOperation::AddColumn,
3189            Some("transport_units"),
3190            "project_id",
3191        );
3192        assert_eq!(reconcile_step(&s, &snap), StepDecision::Execute);
3193    }
3194
3195    #[test]
3196    fn nothing_is_skipped_when_introspection_produced_nothing() {
3197        // A failed or empty introspection must never be read as "the object does not exist".
3198        let snap = DbSnapshot::default();
3199        for op in [
3200            MigrationOperation::AddColumn,
3201            MigrationOperation::SetNotNull,
3202            MigrationOperation::CreateIndex,
3203        ] {
3204            let s = step(op, Some("orders"), "note");
3205            assert_eq!(reconcile_step(&s, &snap), StepDecision::Execute);
3206        }
3207    }
3208
3209    #[test]
3210    fn create_table_is_skipped_when_the_table_exists() {
3211        let mut snap = snapshot();
3212        snap.add_column("app", "orders", "id", facts(false, false));
3213
3214        let s = step(MigrationOperation::CreateTable, Some("orders"), "orders");
3215        assert!(skipped(reconcile_step(&s, &snap)));
3216    }
3217
3218    #[test]
3219    fn rename_is_skipped_once_it_has_been_applied() {
3220        let mut snap = snapshot();
3221        snap.add_column("app", "orders", "state", facts(true, false));
3222
3223        let mut s = step(MigrationOperation::RenameColumn, Some("orders"), "state");
3224        s.from_object = Some("status".into());
3225        assert!(skipped(reconcile_step(&s, &snap)));
3226    }
3227
3228    #[test]
3229    fn rename_runs_while_the_old_column_is_still_there() {
3230        let mut snap = snapshot();
3231        snap.add_column("app", "orders", "status", facts(true, false));
3232
3233        let mut s = step(MigrationOperation::RenameColumn, Some("orders"), "state");
3234        s.from_object = Some("status".into());
3235        assert_eq!(reconcile_step(&s, &snap), StepDecision::Execute);
3236    }
3237
3238    #[test]
3239    fn rename_is_skipped_when_neither_name_exists() {
3240        let mut snap = snapshot();
3241        snap.add_column("app", "orders", "id", facts(false, false));
3242
3243        let mut s = step(MigrationOperation::RenameColumn, Some("orders"), "state");
3244        s.from_object = Some("status".into());
3245        assert!(skipped(reconcile_step(&s, &snap)));
3246    }
3247
3248    #[test]
3249    fn nullability_and_default_steps_respect_the_current_column_state() {
3250        let mut snap = snapshot();
3251        snap.add_column("app", "orders", "not_null_col", facts(false, false));
3252        snap.add_column("app", "orders", "nullable_col", facts(true, true));
3253
3254        assert!(skipped(reconcile_step(
3255            &step(
3256                MigrationOperation::SetNotNull,
3257                Some("orders"),
3258                "not_null_col"
3259            ),
3260            &snap
3261        )));
3262        assert_eq!(
3263            reconcile_step(
3264                &step(
3265                    MigrationOperation::SetNotNull,
3266                    Some("orders"),
3267                    "nullable_col"
3268                ),
3269                &snap
3270            ),
3271            StepDecision::Execute
3272        );
3273        assert!(skipped(reconcile_step(
3274            &step(
3275                MigrationOperation::DropNotNull,
3276                Some("orders"),
3277                "nullable_col"
3278            ),
3279            &snap
3280        )));
3281        assert!(skipped(reconcile_step(
3282            &step(
3283                MigrationOperation::DropDefault,
3284                Some("orders"),
3285                "not_null_col"
3286            ),
3287            &snap
3288        )));
3289        assert_eq!(
3290            reconcile_step(
3291                &step(
3292                    MigrationOperation::DropDefault,
3293                    Some("orders"),
3294                    "nullable_col"
3295                ),
3296                &snap
3297            ),
3298            StepDecision::Execute
3299        );
3300    }
3301
3302    #[test]
3303    fn column_steps_are_skipped_when_the_column_is_gone_from_a_known_table() {
3304        let mut snap = snapshot();
3305        snap.add_column("app", "orders", "id", facts(false, false));
3306
3307        for op in [
3308            MigrationOperation::AlterColumnType,
3309            MigrationOperation::SetDefault,
3310            MigrationOperation::SetNotNull,
3311            MigrationOperation::BackfillNulls,
3312        ] {
3313            assert!(
3314                skipped(reconcile_step(&step(op, Some("orders"), "dropped"), &snap)),
3315                "expected a skip for a column that no longer exists"
3316            );
3317        }
3318    }
3319
3320    #[test]
3321    fn index_and_foreign_key_steps_are_skipped_when_the_object_exists() {
3322        let mut snap = snapshot();
3323        snap.add_column("app", "orders", "user_id", facts(true, false));
3324        snap.add_index("app", "orders_user_idx");
3325        snap.add_constraint("app", "orders", "fk_orders_user");
3326
3327        let mut idx = step(
3328            MigrationOperation::CreateIndex,
3329            Some("orders"),
3330            "orders_user_idx",
3331        );
3332        idx.object_type = "index".into();
3333        assert!(skipped(reconcile_step(&idx, &snap)));
3334
3335        let mut fk = step(
3336            MigrationOperation::AddForeignKey,
3337            Some("orders"),
3338            "fk_orders_user",
3339        );
3340        fk.object_type = "foreign_key".into();
3341        assert!(skipped(reconcile_step(&fk, &snap)));
3342    }
3343
3344    #[test]
3345    fn index_steps_run_when_the_dialect_cannot_report_indexes() {
3346        let mut snap = snapshot();
3347        snap.indexes_known = false;
3348        snap.add_column("app", "orders", "user_id", facts(true, false));
3349
3350        let s = step(
3351            MigrationOperation::CreateIndex,
3352            Some("orders"),
3353            "orders_user_idx",
3354        );
3355        assert_eq!(reconcile_step(&s, &snap), StepDecision::Execute);
3356    }
3357
3358    #[test]
3359    fn executed_steps_are_folded_into_the_snapshot() {
3360        // Later steps must see what earlier ones did, or a plan that adds a column and then
3361        // constrains it would skip the second half.
3362        let mut snap = snapshot();
3363        snap.add_table("app", "orders");
3364
3365        let mut add = step(MigrationOperation::AddColumn, Some("orders"), "note");
3366        add.ddl = Some(r#"ALTER TABLE "app"."orders" ADD COLUMN "note" TEXT NOT NULL"#.into());
3367        apply_step_to_snapshot(&add, &mut snap);
3368        assert!(snap.has_column("app", "orders", "note"));
3369
3370        // Re-running the same step is now a no-op.
3371        assert!(skipped(reconcile_step(&add, &snap)));
3372
3373        // …and the recorded nullability matches the DDL that was executed.
3374        assert!(skipped(reconcile_step(
3375            &step(MigrationOperation::SetNotNull, Some("orders"), "note"),
3376            &snap
3377        )));
3378    }
3379
3380    #[test]
3381    fn drops_and_enum_steps_are_never_skipped() {
3382        let mut snap = snapshot();
3383        snap.add_column("app", "orders", "note", facts(true, false));
3384
3385        for op in [
3386            MigrationOperation::DropIndex,
3387            MigrationOperation::DropForeignKey,
3388            MigrationOperation::CreateEnum,
3389            MigrationOperation::AddEnumValue,
3390        ] {
3391            assert_eq!(
3392                reconcile_step(&step(op, Some("orders"), "note"), &snap),
3393                StepDecision::Execute
3394            );
3395        }
3396    }
3397}
3398
3399#[cfg(all(test, feature = "postgres"))]
3400mod postgres_idempotent_ddl_tests {
3401    use super::*;
3402    use crate::db::postgres::PostgresDialect;
3403
3404    #[test]
3405    fn add_column_carries_an_if_not_exists_guard() {
3406        let ddl = add_column_ddl(&PostgresDialect, r#""app"."orders""#, r#""note" TEXT"#);
3407        assert_eq!(
3408            ddl,
3409            r#"ALTER TABLE "app"."orders" ADD COLUMN IF NOT EXISTS "note" TEXT"#
3410        );
3411    }
3412
3413    #[test]
3414    fn duplicate_object_sqlstates_are_recognised() {
3415        let d = PostgresDialect;
3416        // duplicate_column — what "column already exists" reports.
3417        assert!(d.is_duplicate_object_code("42701"));
3418        // duplicate_table / duplicate_object.
3419        assert!(d.is_duplicate_object_code("42P07"));
3420        assert!(d.is_duplicate_object_code("42710"));
3421        // undefined_column must still be a real failure.
3422        assert!(!d.is_duplicate_object_code("42703"));
3423    }
3424}