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::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/// Apply full config to the database: CREATE SCHEMA, CREATE TYPE, CREATE TABLE, CREATE INDEX, ADD FK.
147/// Validates config first. Idempotent for schemas and types (IF NOT EXISTS); tables are CREATE TABLE only (fails if exists).
148/// 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).
149/// 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)`.
150pub async fn apply_migrations(
151    pool: &Pool,
152    config: &FullConfig,
153    schema_override: Option<&str>,
154    rls_tenant_column: Option<&str>,
155    dialect: &dyn Dialect,
156    cross_package_configs: &HashMap<String, FullConfig>,
157) -> Result<(), AppError> {
158    validate(config)?;
159    let default_sid = config
160        .schemas
161        .first()
162        .map(|s| s.id.as_str())
163        .ok_or_else(|| {
164            AppError::Config(crate::error::ConfigError::Validation(
165                "at least one schema required".into(),
166            ))
167        })?;
168
169    if dialect.supports_schemas() {
170        if let Some(s) = schema_override {
171            let name = quote(s);
172            sqlx::query(&format!("CREATE SCHEMA IF NOT EXISTS {}", name))
173                .execute(pool)
174                .await?;
175        }
176    }
177
178    let schemas_by_id: HashMap<_, _> = config.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
179    let tables_by_id: HashMap<_, _> = config.tables.iter().map(|t| (t.id.as_str(), t)).collect();
180    let columns_by_table: HashMap<_, Vec<&ColumnConfig>> =
181        config.columns.iter().fold(HashMap::new(), |mut m, c| {
182            m.entry(c.table_id.as_str()).or_default().push(c);
183            m
184        });
185
186    // When schema_override is set, we only create the override schema; otherwise create config schemas.
187    if schema_override.is_none() && dialect.supports_schemas() {
188        for s in &config.schemas {
189            let name = quote(&s.name);
190            let comment = s
191                .comment
192                .as_ref()
193                .map(|c| format!("COMMENT ON SCHEMA {} IS '{}'", name, c.replace('\'', "''")));
194            sqlx::query(&format!("CREATE SCHEMA IF NOT EXISTS {}", name))
195                .execute(pool)
196                .await?;
197            if let Some(sql) = comment {
198                let _ = sqlx::query(&sql).execute(pool).await;
199            }
200        }
201    }
202
203    for e in &config.enums {
204        let sid = e.schema_id.as_deref().unwrap_or(default_sid);
205        let schema = schemas_by_id.get(sid).ok_or_else(|| {
206            AppError::Config(crate::error::ConfigError::MissingReference {
207                kind: "schema",
208                id: sid.to_string(),
209            })
210        })?;
211        let schema_name = quote(schema_override.unwrap_or(&schema.name));
212        let type_name = quote(&e.name);
213        if dialect.supports_named_enum_types() {
214            let values: Vec<String> = e
215                .values
216                .iter()
217                .map(|v| format!("'{}'", v.replace('\'', "''")))
218                .collect();
219            let sql = format!(
220                "CREATE TYPE {}.{} AS ENUM ({})",
221                schema_name,
222                type_name,
223                values.join(", ")
224            );
225            let _ = sqlx::query(&sql).execute(pool).await;
226        }
227    }
228
229    for t in &config.tables {
230        let sid = t.schema_id.as_deref().unwrap_or(default_sid);
231        let schema = schemas_by_id.get(sid).ok_or_else(|| {
232            AppError::Config(crate::error::ConfigError::MissingReference {
233                kind: "schema",
234                id: sid.to_string(),
235            })
236        })?;
237        let schema_name = quote(schema_override.unwrap_or(&schema.name));
238        let table_name = quote(&t.name);
239        let full_name = format!("{}.{}", schema_name, table_name);
240
241        let cols = columns_by_table
242            .get(t.id.as_str())
243            .map(|v| v.as_slice())
244            .unwrap_or(&[]);
245        let mut col_defs: Vec<String> = Vec::new();
246        for c in cols {
247            let typ = dialect.ddl_type(&parse_canonical(&c.type_));
248            let mut def = format!("{} {}", quote(&c.name), typ);
249            if !c.nullable {
250                def.push_str(" NOT NULL");
251            }
252            if let Some(ref d) = c.default {
253                def.push_str(" DEFAULT ");
254                match d {
255                    ColumnDefaultConfig::Literal(s) => def.push_str(s),
256                    ColumnDefaultConfig::Expression { expression } => def.push_str(expression),
257                }
258            }
259            col_defs.push(def);
260        }
261
262        let config_col_names: HashSet<&str> = cols.iter().map(|c| c.name.as_str()).collect();
263        let ts_default = format!(
264            "{} NOT NULL DEFAULT {}",
265            dialect.sys_timestamp_type(),
266            dialect.now_fn()
267        );
268        let ts_nullable = dialect.sys_timestamp_type().to_string();
269        for (name, def_suffix) in [
270            ("created_at", ts_default.as_str()),
271            ("updated_at", ts_default.as_str()),
272            ("archived_at", ts_nullable.as_str()),
273            ("created_by", "TEXT"),
274            ("updated_by", "TEXT"),
275        ] {
276            if !config_col_names.contains(name) {
277                col_defs.push(format!("{} {}", quote(name), def_suffix));
278            }
279        }
280
281        let pk_cols = match &t.primary_key {
282            PrimaryKeyConfig::Single(s) => vec![quote(s)],
283            PrimaryKeyConfig::Composite(v) => v.iter().map(|s| quote(s)).collect::<Vec<_>>(),
284        };
285        let pk_def = format!("PRIMARY KEY ({})", pk_cols.join(", "));
286        col_defs.push(pk_def);
287
288        for u in &t.unique {
289            let cols: Vec<String> = u.iter().map(|s| quote(s)).collect();
290            col_defs.push(format!("UNIQUE ({})", cols.join(", ")));
291        }
292        for ch in &t.check {
293            col_defs.push(format!(
294                "CONSTRAINT {} CHECK ({})",
295                quote(&ch.name),
296                ch.expression
297            ));
298        }
299
300        let sql = format!(
301            "CREATE TABLE IF NOT EXISTS {} (\n  {}\n)",
302            full_name,
303            col_defs.join(",\n  ")
304        );
305        sqlx::query(&sql).execute(pool).await?;
306
307        if t.audit_log {
308            let schema_raw = schema_override.unwrap_or(&schema.name);
309            let audit_sql = audit_table_ddl(schema_raw, &t.name, cols, dialect);
310            sqlx::query(&audit_sql).execute(pool).await?;
311            let pk_col = match &t.primary_key {
312                PrimaryKeyConfig::Single(s) => s.clone(),
313                PrimaryKeyConfig::Composite(v) => v[0].clone(),
314            };
315            let audit_full = format!(
316                "{}.{}",
317                quote(schema_raw),
318                quote(&format!("{}_audit", t.name))
319            );
320            let idx_sql = format!(
321                "CREATE INDEX IF NOT EXISTS {} ON {} ({}, {})",
322                quote(&format!("{}_audit_record_idx", t.name)),
323                audit_full,
324                quote(&pk_col),
325                quote("audit_at")
326            );
327            let _ = sqlx::query(&idx_sql).execute(pool).await;
328        }
329
330        if t.versioning.as_ref().is_some_and(|v| v.enabled) {
331            let schema_raw = schema_override.unwrap_or(&schema.name);
332            let pk_col = match &t.primary_key {
333                PrimaryKeyConfig::Single(s) => s.clone(),
334                PrimaryKeyConfig::Composite(v) => v[0].clone(),
335            };
336            let history_ddl = history_table_ddl(schema_raw, &t.name, &pk_col, cols, dialect);
337            // history_table_ddl embeds a comment line for the index; execute CREATE TABLE only
338            let create_only = history_ddl
339                .lines()
340                .take_while(|l| !l.trim_start().starts_with("-- index:"))
341                .collect::<Vec<_>>()
342                .join("\n");
343            sqlx::query(create_only.trim()).execute(pool).await?;
344            let idx_sql = history_index_ddl(schema_raw, &t.name, &pk_col);
345            let _ = sqlx::query(&idx_sql).execute(pool).await;
346        }
347    }
348
349    if let Some(col) = rls_tenant_column {
350        apply_rls_to_tables(pool, config, schema_override, col, dialect).await?;
351    }
352
353    for idx in &config.indexes {
354        let sid = idx.schema_id.as_deref().unwrap_or(default_sid);
355        let schema = schemas_by_id.get(sid).ok_or_else(|| {
356            AppError::Config(crate::error::ConfigError::MissingReference {
357                kind: "schema",
358                id: sid.to_string(),
359            })
360        })?;
361        let table = tables_by_id.get(idx.table_id.as_str()).ok_or_else(|| {
362            AppError::Config(crate::error::ConfigError::MissingReference {
363                kind: "table",
364                id: idx.table_id.clone(),
365            })
366        })?;
367        let schema_name = quote(schema_override.unwrap_or(&schema.name));
368        let table_name = quote(&table.name);
369        let full_table = format!("{}.{}", schema_name, table_name);
370        let index_name = quote(&idx.name);
371
372        let mut col_parts: Vec<String> = Vec::new();
373        for col in &idx.columns {
374            match col {
375                IndexColumnEntry::Name(n) => col_parts.push(quote(n)),
376                IndexColumnEntry::Spec {
377                    name, direction, ..
378                } => {
379                    let dir = direction
380                        .as_deref()
381                        .map(|d| format!(" {}", d.to_uppercase()))
382                        .unwrap_or_default();
383                    col_parts.push(format!("{}{}", quote(name), dir));
384                }
385                IndexColumnEntry::Expression { expression } => col_parts.push(expression.clone()),
386            }
387        }
388        let method = idx.method.as_deref().unwrap_or("btree");
389        let unique = if idx.unique { "UNIQUE " } else { "" };
390        let include: String = if idx.include.is_empty() {
391            String::new()
392        } else {
393            let inc: Vec<String> = idx.include.iter().map(|s| quote(s)).collect();
394            format!(" INCLUDE ({})", inc.join(", "))
395        };
396        let where_clause: String = idx
397            .where_
398            .as_ref()
399            .map(|w| format!(" WHERE {}", w))
400            .unwrap_or_default();
401
402        let sql = format!(
403            "CREATE {}INDEX IF NOT EXISTS {} ON {} USING {} ({}){}{}",
404            unique,
405            index_name,
406            full_table,
407            method,
408            col_parts.join(", "),
409            include,
410            where_clause
411        );
412        let _ = sqlx::query(&sql).execute(pool).await;
413    }
414
415    for rel in &config.relationships {
416        let from_sid = rel.from_schema_id.as_deref().unwrap_or(default_sid);
417        let from_schema = schemas_by_id.get(from_sid).ok_or_else(|| {
418            AppError::Config(crate::error::ConfigError::MissingReference {
419                kind: "schema",
420                id: from_sid.to_string(),
421            })
422        })?;
423        let from_table = tables_by_id
424            .get(rel.from_table_id.as_str())
425            .ok_or_else(|| {
426                AppError::Config(crate::error::ConfigError::MissingReference {
427                    kind: "table",
428                    id: rel.from_table_id.clone(),
429                })
430            })?;
431
432        // Resolve the target schema and table — either from a cross-package config or this config.
433        let (to_schema_name_owned, to_table_name, to_col_name) = if let Some(pkg_id) =
434            rel.to_package_id.as_deref()
435        {
436            let foreign = cross_package_configs.get(pkg_id).ok_or_else(|| {
437                AppError::Config(crate::error::ConfigError::MissingReference {
438                    kind: "cross_package",
439                    id: pkg_id.to_string(),
440                })
441            })?;
442            let foreign_tables: HashMap<_, _> =
443                foreign.tables.iter().map(|t| (t.id.as_str(), t)).collect();
444            let foreign_schemas: HashMap<_, _> =
445                foreign.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
446            let to_tbl = foreign_tables
447                .get(rel.to_table_id.as_str())
448                .ok_or_else(|| {
449                    AppError::Config(crate::error::ConfigError::MissingReference {
450                        kind: "table",
451                        id: rel.to_table_id.clone(),
452                    })
453                })?;
454            let foreign_default_sid = foreign.schemas.first().map(|s| s.id.as_str()).unwrap_or("");
455            let to_sid = rel.to_schema_id.as_deref().unwrap_or(foreign_default_sid);
456            let to_schema = foreign_schemas.get(to_sid).ok_or_else(|| {
457                AppError::Config(crate::error::ConfigError::MissingReference {
458                    kind: "schema",
459                    id: to_sid.to_string(),
460                })
461            })?;
462            let col_name = foreign
463                .columns
464                .iter()
465                .find(|c| c.id == rel.to_column_id)
466                .map(|c| c.name.clone())
467                .ok_or_else(|| {
468                    AppError::Config(crate::error::ConfigError::MissingReference {
469                        kind: "column",
470                        id: rel.to_column_id.clone(),
471                    })
472                })?;
473            // Cross-package FKs always use the real schema name (no schema_override — the
474            // target package lives in its own schema, not the tenant override).
475            (to_schema.name.clone(), to_tbl.name.clone(), col_name)
476        } else {
477            let to_sid = rel.to_schema_id.as_deref().unwrap_or(default_sid);
478            let to_schema = schemas_by_id.get(to_sid).ok_or_else(|| {
479                AppError::Config(crate::error::ConfigError::MissingReference {
480                    kind: "schema",
481                    id: to_sid.to_string(),
482                })
483            })?;
484            let to_table = tables_by_id.get(rel.to_table_id.as_str()).ok_or_else(|| {
485                AppError::Config(crate::error::ConfigError::MissingReference {
486                    kind: "table",
487                    id: rel.to_table_id.clone(),
488                })
489            })?;
490            let col_name = config
491                .columns
492                .iter()
493                .find(|c| c.id == rel.to_column_id)
494                .map(|c| c.name.clone())
495                .ok_or_else(|| {
496                    AppError::Config(crate::error::ConfigError::MissingReference {
497                        kind: "column",
498                        id: rel.to_column_id.clone(),
499                    })
500                })?;
501            (
502                schema_override.unwrap_or(&to_schema.name).to_string(),
503                to_table.name.clone(),
504                col_name,
505            )
506        };
507
508        let from_schema_name = schema_override.unwrap_or(&from_schema.name);
509        let from_col = config
510            .columns
511            .iter()
512            .find(|c| c.id == rel.from_column_id)
513            .map(|c| c.name.as_str())
514            .ok_or_else(|| {
515                AppError::Config(crate::error::ConfigError::MissingReference {
516                    kind: "column",
517                    id: rel.from_column_id.clone(),
518                })
519            })?;
520
521        let from_full = format!("{}.{}", quote(from_schema_name), quote(&from_table.name));
522        let to_full = format!("{}.{}", quote(&to_schema_name_owned), quote(&to_table_name));
523        let constraint_name = rel.name.as_deref().unwrap_or(&rel.id);
524        let on_update = rel.on_update.as_deref().unwrap_or("NO ACTION");
525        let on_delete = rel.on_delete.as_deref().unwrap_or("NO ACTION");
526
527        let sql = format!(
528            "ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({}) ON UPDATE {} ON DELETE {}",
529            from_full,
530            quote(constraint_name),
531            quote(from_col),
532            to_full,
533            quote(&to_col_name),
534            on_update,
535            on_delete
536        );
537        let _ = sqlx::query(&sql).execute(pool).await;
538    }
539
540    Ok(())
541}
542
543/// Revert migrations for a package: drop tables, enum types, and schema (if not public) in reverse order of apply.
544/// Uses the same schema_override as apply_migrations (tables/enums live in that schema).
545pub async fn revert_migrations(
546    pool: &Pool,
547    config: &FullConfig,
548    schema_override: Option<&str>,
549) -> Result<(), AppError> {
550    let default_sid = config
551        .schemas
552        .first()
553        .map(|s| s.id.as_str())
554        .ok_or_else(|| {
555            AppError::Config(crate::error::ConfigError::Validation(
556                "at least one schema required".into(),
557            ))
558        })?;
559
560    let schemas_by_id: HashMap<_, _> = config.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
561
562    // 1. Drop tables (CASCADE drops FKs and dependent objects)
563    for t in &config.tables {
564        let sid = t.schema_id.as_deref().unwrap_or(default_sid);
565        let schema = schemas_by_id.get(sid).ok_or_else(|| {
566            AppError::Config(crate::error::ConfigError::MissingReference {
567                kind: "schema",
568                id: sid.to_string(),
569            })
570        })?;
571        let schema_raw = schema_override.unwrap_or(&schema.name);
572        let schema_name = quote(schema_raw);
573        let table_name = quote(&t.name);
574        let full_name = format!("{}.{}", schema_name, table_name);
575        if t.audit_log {
576            let audit_full = format!("{}.{}", schema_name, quote(&format!("{}_audit", t.name)));
577            let _ = sqlx::query(&format!("DROP TABLE IF EXISTS {} CASCADE", audit_full))
578                .execute(pool)
579                .await;
580        }
581        if t.versioning.as_ref().is_some_and(|v| v.enabled) {
582            let history_full = format!("{}.{}", schema_name, quote(&format!("{}_history", t.name)));
583            let _ = sqlx::query(&format!("DROP TABLE IF EXISTS {} CASCADE", history_full))
584                .execute(pool)
585                .await;
586        }
587        let drop_sql = format!("DROP TABLE IF EXISTS {} CASCADE", full_name);
588        let _ = sqlx::query(&drop_sql).execute(pool).await;
589    }
590
591    // 2. Drop enum types
592    for e in &config.enums {
593        let sid = e.schema_id.as_deref().unwrap_or(default_sid);
594        let schema = schemas_by_id.get(sid).ok_or_else(|| {
595            AppError::Config(crate::error::ConfigError::MissingReference {
596                kind: "schema",
597                id: sid.to_string(),
598            })
599        })?;
600        let schema_name = quote(schema_override.unwrap_or(&schema.name));
601        let type_name = quote(&e.name);
602        let drop_sql = format!("DROP TYPE IF EXISTS {}.{} CASCADE", schema_name, type_name);
603        let _ = sqlx::query(&drop_sql).execute(pool).await;
604    }
605
606    // 3. Drop schema only if not public (shared schema)
607    if schema_override.is_none() {
608        for s in &config.schemas {
609            if s.name.eq_ignore_ascii_case("public") {
610                continue;
611            }
612            let schema_name = quote(&s.name);
613            let drop_sql = format!("DROP SCHEMA IF EXISTS {} CASCADE", schema_name);
614            let _ = sqlx::query(&drop_sql).execute(pool).await;
615        }
616    }
617
618    Ok(())
619}
620
621// ─── Migration plan types ────────────────────────────────────────────────────
622
623#[derive(Debug, Clone, Serialize, Deserialize)]
624#[serde(rename_all = "snake_case")]
625pub enum MigrationOperation {
626    CreateSchema,
627    CreateEnum,
628    DropEnum,
629    AddEnumValue,
630    RemoveEnumValue,
631    CreateTable,
632    DropTable,
633    AddColumn,
634    DropColumn,
635    RenameColumn,
636    AlterColumnType,
637    BackfillNulls,
638    SetNotNull,
639    DropNotNull,
640    SetDefault,
641    DropDefault,
642    CreateIndex,
643    DropIndex,
644    AddForeignKey,
645    DropForeignKey,
646}
647
648impl std::fmt::Display for MigrationOperation {
649    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
650        let s = serde_json::to_value(self)
651            .ok()
652            .and_then(|v| v.as_str().map(String::from))
653            .unwrap_or_else(|| format!("{:?}", self));
654        write!(f, "{}", s)
655    }
656}
657
658/// How safely a migration step can be executed.
659#[derive(Debug, Clone, Serialize, Deserialize)]
660#[serde(rename_all = "snake_case")]
661pub enum MigrationSafety {
662    /// Guaranteed to succeed, no data impact.
663    Safe,
664    /// Attempted; execution failure is captured as a warning instead of aborting.
665    BestEffort,
666    /// No DDL generated — config change noted as a warning only (e.g. removed tables/columns).
667    WarnOnly,
668}
669
670/// Risk category associated with a migration step.
671#[derive(Debug, Clone, Serialize, Deserialize)]
672#[serde(rename_all = "snake_case")]
673pub enum MigrationRisk {
674    None,
675    /// Cast may fail for incompatible values (e.g. TEXT → INTEGER).
676    MayFail,
677    /// SET NOT NULL will fail if any existing row has NULL in this column.
678    ExistingNullsMustBeAbsent,
679    /// Existing NULL rows will be overwritten with the column default.
680    DataWillBeModified,
681    /// Cannot be automated — requires a manual database action.
682    ManualActionRequired,
683}
684
685/// One step in a migration plan: a DDL statement with metadata.
686#[derive(Debug, Clone, Serialize, Deserialize)]
687pub struct MigrationStep {
688    pub step: usize,
689    pub operation: MigrationOperation,
690    pub schema: String,
691    pub table: Option<String>,
692    /// Column name, index name, FK constraint name, enum name, etc.
693    pub object: String,
694    /// "column" | "table" | "index" | "foreign_key" | "enum" | "enum_value" | "schema"
695    pub object_type: String,
696    pub description: String,
697    /// The SQL to execute. None for WarnOnly steps.
698    pub ddl: Option<String>,
699    pub safety: MigrationSafety,
700    pub risk: MigrationRisk,
701    pub risk_detail: Option<String>,
702}
703
704/// Computed diff between two package versions expressed as ordered migration steps.
705#[derive(Debug, Clone, Serialize, Deserialize)]
706pub struct MigrationPlan {
707    pub steps: Vec<MigrationStep>,
708}
709
710#[derive(Debug, Clone, Serialize)]
711pub struct MigrationSummary {
712    pub total: usize,
713    pub safe: usize,
714    pub best_effort: usize,
715    pub warn_only: usize,
716}
717
718impl MigrationPlan {
719    pub fn summary(&self) -> MigrationSummary {
720        let (mut safe, mut best_effort, mut warn_only) = (0, 0, 0);
721        for s in &self.steps {
722            match s.safety {
723                MigrationSafety::Safe => safe += 1,
724                MigrationSafety::BestEffort => best_effort += 1,
725                MigrationSafety::WarnOnly => warn_only += 1,
726            }
727        }
728        MigrationSummary {
729            total: self.steps.len(),
730            safe,
731            best_effort,
732            warn_only,
733        }
734    }
735}
736
737/// Result returned by `execute_migration_plan`.
738pub struct MigrationExecutionResult {
739    pub applied: usize,
740    pub warned: usize,
741    pub warnings: Vec<String>,
742}
743
744fn default_str(d: &ColumnDefaultConfig) -> String {
745    match d {
746        ColumnDefaultConfig::Literal(s) => s.clone(),
747        ColumnDefaultConfig::Expression { expression } => expression.clone(),
748    }
749}
750
751/// A column whose type is (an array of) a given enum — i.e. one that must be recast when the
752/// enum type is rebuilt.
753struct EnumColumnRef {
754    schema: String,
755    table: String,
756    column: String,
757    default: Option<String>,
758    is_array: bool,
759}
760
761/// If `t` is a custom enum reference (`schema.name`, bare `name`, or an array of one), return
762/// `(enum_name, is_array)` where `enum_name` is the unqualified type name. None for built-in types.
763fn enum_type_name(t: &crate::db::CanonicalType) -> Option<(String, bool)> {
764    use crate::db::CanonicalType;
765    let unqualified = |s: &str| s.rsplit('.').next().unwrap_or(s).to_string();
766    match t {
767        CanonicalType::Custom(s) => Some((unqualified(s), false)),
768        CanonicalType::Array(inner) => match inner.as_ref() {
769            CanonicalType::Custom(s) => Some((unqualified(s), true)),
770            _ => None,
771        },
772        _ => None,
773    }
774}
775
776/// Find every column in `new` whose type is `new_enum` (or an array of it), resolved to its live
777/// table/schema name. Used to drive the recast steps of an enum rebuild.
778fn enum_dependent_columns(
779    new_enum: &EnumConfig,
780    new: &FullConfig,
781    new_tables: &HashMap<&str, &TableConfig>,
782    new_schemas: &HashMap<&str, &SchemaConfig>,
783    schema_override: Option<&str>,
784) -> Vec<EnumColumnRef> {
785    let default_sid = new.schemas.first().map(|s| s.id.as_str()).unwrap_or("");
786    let mut out = Vec::new();
787    for c in &new.columns {
788        let Some((tyname, is_array)) = enum_type_name(&parse_canonical(&c.type_)) else {
789            continue;
790        };
791        if tyname != new_enum.name {
792            continue;
793        }
794        let Some(table) = new_tables.get(c.table_id.as_str()) else {
795            continue;
796        };
797        let tsid = table.schema_id.as_deref().unwrap_or(default_sid);
798        let schema = schema_override.map(String::from).unwrap_or_else(|| {
799            new_schemas
800                .get(tsid)
801                .map(|s| s.name.clone())
802                .unwrap_or_else(|| tsid.to_string())
803        });
804        out.push(EnumColumnRef {
805            schema,
806            table: table.name.clone(),
807            column: c.name.clone(),
808            default: c.default.as_ref().map(default_str),
809            is_array,
810        });
811    }
812    out
813}
814
815/// PostgreSQL cannot drop a value from an enum in place, so when one is removed the type must be
816/// rebuilt: rename the live type aside, create it afresh with the reduced value set, recast every
817/// dependent column through a text cast, then drop the old type. Steps are emitted as `BestEffort`
818/// — a recast that fails because a row still holds a removed value is surfaced as a warning rather
819/// than aborting the whole upgrade. Each statement is a separate step because `execute_migration_plan`
820/// runs them individually (the extended protocol forbids multiple statements per query).
821fn recreate_enum_steps(
822    steps: &mut Vec<MigrationStep>,
823    schema: &str,
824    new_enum: &EnumConfig,
825    removed: &[&str],
826    dependents: &[EnumColumnRef],
827) {
828    let type_q = format!("{}.{}", quote(schema), quote(&new_enum.name));
829    let tmp_name = format!("{}__arch_old", new_enum.name);
830    let values: Vec<String> = new_enum
831        .values
832        .iter()
833        .map(|v| format!("'{}'", v.replace('\'', "''")))
834        .collect();
835
836    // 0. Informational summary of the destructive rebuild (no DDL).
837    steps.push(MigrationStep {
838        step: 0,
839        operation: MigrationOperation::RemoveEnumValue,
840        schema: schema.to_string(),
841        table: None,
842        object: format!("{}:{}", new_enum.name, removed.join(",")),
843        object_type: "enum".into(),
844        description: format!(
845            "Rebuild enum \"{}\".\"{}\" to remove value(s): {}",
846            schema,
847            new_enum.name,
848            removed.join(", ")
849        ),
850        ddl: None,
851        safety: MigrationSafety::WarnOnly,
852        risk: MigrationRisk::ManualActionRequired,
853        risk_detail: Some(format!(
854            "PostgreSQL cannot drop enum values in place. The type is rebuilt and {} dependent \
855             column(s) are recast via a text cast. Any existing row holding a removed value ({}) \
856             will make its recast fail — reassign those rows first.",
857            dependents.len(),
858            removed.join(", ")
859        )),
860    });
861
862    // 1. Rename the live type aside.
863    steps.push(MigrationStep {
864        step: 0,
865        operation: MigrationOperation::DropEnum,
866        schema: schema.to_string(),
867        table: None,
868        object: new_enum.name.clone(),
869        object_type: "enum".into(),
870        description: format!(
871            "Rename enum \"{}\".\"{}\" to \"{}\" before rebuild",
872            schema, new_enum.name, tmp_name
873        ),
874        ddl: Some(format!(
875            "ALTER TYPE {} RENAME TO {}",
876            type_q,
877            quote(&tmp_name)
878        )),
879        safety: MigrationSafety::BestEffort,
880        risk: MigrationRisk::None,
881        risk_detail: None,
882    });
883
884    // 2. Create the type afresh with the reduced value set (folds in any added values too).
885    steps.push(MigrationStep {
886        step: 0,
887        operation: MigrationOperation::CreateEnum,
888        schema: schema.to_string(),
889        table: None,
890        object: new_enum.name.clone(),
891        object_type: "enum".into(),
892        description: format!(
893            "Recreate enum \"{}\".\"{}\" with {} value(s)",
894            schema,
895            new_enum.name,
896            new_enum.values.len()
897        ),
898        ddl: Some(format!(
899            "CREATE TYPE {} AS ENUM ({})",
900            type_q,
901            values.join(", ")
902        )),
903        safety: MigrationSafety::BestEffort,
904        risk: MigrationRisk::None,
905        risk_detail: None,
906    });
907
908    // 3. Recast every dependent column from the renamed type onto the rebuilt one. A column with a
909    //    default must drop it first (the default still references the renamed type) and restore it after.
910    for dep in dependents {
911        let table_q = format!("{}.{}", quote(&dep.schema), quote(&dep.table));
912        let col_q = quote(&dep.column);
913
914        if dep.default.is_some() {
915            steps.push(MigrationStep {
916                step: 0,
917                operation: MigrationOperation::DropDefault,
918                schema: dep.schema.clone(),
919                table: Some(dep.table.clone()),
920                object: dep.column.clone(),
921                object_type: "column".into(),
922                description: format!(
923                    "Drop default on {}.{} before enum recast",
924                    dep.table, dep.column
925                ),
926                ddl: Some(format!(
927                    "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT",
928                    table_q, col_q
929                )),
930                safety: MigrationSafety::BestEffort,
931                risk: MigrationRisk::None,
932                risk_detail: None,
933            });
934        }
935
936        let (col_type, using) = if dep.is_array {
937            (
938                format!("{}[]", type_q),
939                format!("{}::text[]::{}[]", col_q, type_q),
940            )
941        } else {
942            (type_q.clone(), format!("{}::text::{}", col_q, type_q))
943        };
944        steps.push(MigrationStep {
945            step: 0,
946            operation: MigrationOperation::AlterColumnType,
947            schema: dep.schema.clone(),
948            table: Some(dep.table.clone()),
949            object: dep.column.clone(),
950            object_type: "column".into(),
951            description: format!(
952                "Recast {}.{} onto rebuilt enum \"{}\"",
953                dep.table, dep.column, new_enum.name
954            ),
955            ddl: Some(format!(
956                "ALTER TABLE {} ALTER COLUMN {} TYPE {} USING {}",
957                table_q, col_q, col_type, using
958            )),
959            safety: MigrationSafety::BestEffort,
960            risk: MigrationRisk::MayFail,
961            risk_detail: Some(format!(
962                "Cast fails if any row holds a removed value ({}).",
963                removed.join(", ")
964            )),
965        });
966
967        if let Some(def) = &dep.default {
968            steps.push(MigrationStep {
969                step: 0,
970                operation: MigrationOperation::SetDefault,
971                schema: dep.schema.clone(),
972                table: Some(dep.table.clone()),
973                object: dep.column.clone(),
974                object_type: "column".into(),
975                description: format!(
976                    "Restore default on {}.{} after enum recast",
977                    dep.table, dep.column
978                ),
979                ddl: Some(format!(
980                    "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {}",
981                    table_q, col_q, def
982                )),
983                safety: MigrationSafety::BestEffort,
984                risk: MigrationRisk::None,
985                risk_detail: None,
986            });
987        }
988    }
989
990    // 4. Drop the renamed original type.
991    steps.push(MigrationStep {
992        step: 0,
993        operation: MigrationOperation::DropEnum,
994        schema: schema.to_string(),
995        table: None,
996        object: tmp_name.clone(),
997        object_type: "enum".into(),
998        description: format!("Drop superseded enum \"{}\".\"{}\"", schema, tmp_name),
999        ddl: Some(format!(
1000            "DROP TYPE IF EXISTS {}.{}",
1001            quote(schema),
1002            quote(&tmp_name)
1003        )),
1004        safety: MigrationSafety::BestEffort,
1005        risk: MigrationRisk::None,
1006        risk_detail: None,
1007    });
1008}
1009
1010// ─── compute_migration_plan ──────────────────────────────────────────────────
1011
1012/// Diff two package configs and produce an ordered list of migration steps.
1013/// This is a pure function — it does not touch the database.
1014/// Pass the result to `execute_migration_plan` after user confirmation.
1015pub fn compute_migration_plan(
1016    old: &FullConfig,
1017    new: &FullConfig,
1018    schema_override: Option<&str>,
1019    _rls_tenant_column: Option<&str>,
1020    dialect: &dyn Dialect,
1021    cross_package_configs: &HashMap<String, FullConfig>,
1022) -> Result<MigrationPlan, AppError> {
1023    validate(new)?;
1024
1025    let default_old_sid = old.schemas.first().map(|s| s.id.as_str()).unwrap_or("");
1026    let default_new_sid = new.schemas.first().map(|s| s.id.as_str()).unwrap_or("");
1027
1028    let old_schemas: HashMap<&str, &SchemaConfig> =
1029        old.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
1030    let new_schemas: HashMap<&str, &SchemaConfig> =
1031        new.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
1032    let old_tables: HashMap<&str, &TableConfig> =
1033        old.tables.iter().map(|t| (t.id.as_str(), t)).collect();
1034    let new_tables: HashMap<&str, &TableConfig> =
1035        new.tables.iter().map(|t| (t.id.as_str(), t)).collect();
1036    let old_columns: HashMap<&str, &ColumnConfig> =
1037        old.columns.iter().map(|c| (c.id.as_str(), c)).collect();
1038    let old_enums: HashMap<&str, &EnumConfig> =
1039        old.enums.iter().map(|e| (e.id.as_str(), e)).collect();
1040    let new_enums: HashMap<&str, &EnumConfig> =
1041        new.enums.iter().map(|e| (e.id.as_str(), e)).collect();
1042    let old_indexes: HashMap<&str, &IndexConfig> =
1043        old.indexes.iter().map(|i| (i.id.as_str(), i)).collect();
1044    let new_indexes: HashMap<&str, &IndexConfig> =
1045        new.indexes.iter().map(|i| (i.id.as_str(), i)).collect();
1046    let old_rels: HashMap<&str, &RelationshipConfig> = old
1047        .relationships
1048        .iter()
1049        .map(|r| (r.id.as_str(), r))
1050        .collect();
1051    let new_rels: HashMap<&str, &RelationshipConfig> = new
1052        .relationships
1053        .iter()
1054        .map(|r| (r.id.as_str(), r))
1055        .collect();
1056
1057    let mut steps: Vec<MigrationStep> = Vec::new();
1058
1059    let schema_name_for = |sid: &str, schemas: &HashMap<&str, &SchemaConfig>| -> String {
1060        schema_override.map(String::from).unwrap_or_else(|| {
1061            schemas
1062                .get(sid)
1063                .map(|s| s.name.clone())
1064                .unwrap_or_else(|| sid.to_string())
1065        })
1066    };
1067
1068    // ── 1. New schemas ───────────────────────────────────────────────────────
1069    if schema_override.is_none() {
1070        for s in &new.schemas {
1071            if !old_schemas.contains_key(s.id.as_str()) {
1072                steps.push(MigrationStep {
1073                    step: 0,
1074                    operation: MigrationOperation::CreateSchema,
1075                    schema: s.name.clone(),
1076                    table: None,
1077                    object: s.name.clone(),
1078                    object_type: "schema".into(),
1079                    description: format!("Create schema \"{}\"", s.name),
1080                    ddl: Some(format!("CREATE SCHEMA IF NOT EXISTS {}", quote(&s.name))),
1081                    safety: MigrationSafety::Safe,
1082                    risk: MigrationRisk::None,
1083                    risk_detail: None,
1084                });
1085            }
1086        }
1087    }
1088
1089    // ── 2. Enums ─────────────────────────────────────────────────────────────
1090    for new_enum in &new.enums {
1091        let sid = new_enum.schema_id.as_deref().unwrap_or(default_new_sid);
1092        let schema = schema_name_for(sid, &new_schemas);
1093
1094        if let Some(old_enum) = old_enums.get(new_enum.id.as_str()) {
1095            let old_vals: HashSet<&str> = old_enum.values.iter().map(String::as_str).collect();
1096            let new_vals: HashSet<&str> = new_enum.values.iter().map(String::as_str).collect();
1097            let removed: Vec<&str> = old_enum
1098                .values
1099                .iter()
1100                .map(String::as_str)
1101                .filter(|v| !new_vals.contains(v))
1102                .collect();
1103
1104            if removed.is_empty() {
1105                // Purely additive — append each new value in place (cheap, non-destructive).
1106                for val in new_enum
1107                    .values
1108                    .iter()
1109                    .map(String::as_str)
1110                    .filter(|v| !old_vals.contains(v))
1111                {
1112                    steps.push(MigrationStep {
1113                        step: 0,
1114                        operation: MigrationOperation::AddEnumValue,
1115                        schema: schema.clone(),
1116                        table: None,
1117                        object: format!("{}:{}", new_enum.name, val),
1118                        object_type: "enum_value".into(),
1119                        description: format!(
1120                            "Add value '{}' to enum \"{}\".\"{}\"",
1121                            val, schema, new_enum.name
1122                        ),
1123                        ddl: Some(format!(
1124                            "ALTER TYPE {}.{} ADD VALUE IF NOT EXISTS '{}'",
1125                            quote(&schema),
1126                            quote(&new_enum.name),
1127                            val.replace('\'', "''")
1128                        )),
1129                        safety: MigrationSafety::Safe,
1130                        risk: MigrationRisk::None,
1131                        risk_detail: None,
1132                    });
1133                }
1134            } else {
1135                // One or more values removed. PostgreSQL has no DROP VALUE, so rebuild the type and
1136                // recast every dependent column. Added values (if any) are folded into the rebuilt
1137                // value list, so no separate ADD VALUE step is needed.
1138                let dependents = enum_dependent_columns(
1139                    new_enum,
1140                    new,
1141                    &new_tables,
1142                    &new_schemas,
1143                    schema_override,
1144                );
1145                recreate_enum_steps(&mut steps, &schema, new_enum, &removed, &dependents);
1146            }
1147        } else {
1148            let values: Vec<String> = new_enum
1149                .values
1150                .iter()
1151                .map(|v| format!("'{}'", v.replace('\'', "''")))
1152                .collect();
1153            steps.push(MigrationStep {
1154                step: 0,
1155                operation: MigrationOperation::CreateEnum,
1156                schema: schema.clone(),
1157                table: None,
1158                object: new_enum.name.clone(),
1159                object_type: "enum".into(),
1160                description: format!("Create enum type \"{}\".\"{}\"", schema, new_enum.name),
1161                ddl: Some(format!("CREATE TYPE {}.{} AS ENUM ({})", quote(&schema), quote(&new_enum.name), values.join(", "))),
1162                safety: MigrationSafety::BestEffort,
1163                risk: MigrationRisk::None,
1164                risk_detail: Some("PostgreSQL has no CREATE TYPE IF NOT EXISTS; ignored if the type already exists.".into()),
1165            });
1166        }
1167    }
1168    for old_enum in &old.enums {
1169        if !new_enums.contains_key(old_enum.id.as_str()) {
1170            let sid = old_enum.schema_id.as_deref().unwrap_or(default_old_sid);
1171            let schema = schema_name_for(sid, &old_schemas);
1172            steps.push(MigrationStep {
1173                step: 0,
1174                operation: MigrationOperation::DropEnum,
1175                schema: schema.clone(),
1176                table: None,
1177                object: old_enum.name.clone(),
1178                object_type: "enum".into(),
1179                description: format!("Enum \"{}\".\"{}\" removed from config", schema, old_enum.name),
1180                ddl: None,
1181                safety: MigrationSafety::WarnOnly,
1182                risk: MigrationRisk::ManualActionRequired,
1183                risk_detail: Some("Enum type NOT dropped from database (data safety). Run DROP TYPE manually if intended.".into()),
1184            });
1185        }
1186    }
1187
1188    // ── 3. New and removed tables ────────────────────────────────────────────
1189    let added_table_ids: HashSet<&str> = new
1190        .tables
1191        .iter()
1192        .filter(|t| !old_tables.contains_key(t.id.as_str()))
1193        .map(|t| t.id.as_str())
1194        .collect();
1195
1196    let cols_by_table: HashMap<&str, Vec<&ColumnConfig>> =
1197        new.columns.iter().fold(HashMap::new(), |mut m, c| {
1198            m.entry(c.table_id.as_str()).or_default().push(c);
1199            m
1200        });
1201
1202    for new_table in &new.tables {
1203        if !added_table_ids.contains(new_table.id.as_str()) {
1204            continue;
1205        }
1206        let sid = new_table.schema_id.as_deref().unwrap_or(default_new_sid);
1207        let schema = schema_name_for(sid, &new_schemas);
1208        let full = format!("{}.{}", quote(&schema), quote(&new_table.name));
1209
1210        let cols = cols_by_table
1211            .get(new_table.id.as_str())
1212            .map(|v| v.as_slice())
1213            .unwrap_or(&[]);
1214        let mut col_defs: Vec<String> = Vec::new();
1215        for c in cols {
1216            let typ = dialect.ddl_type(&parse_canonical(&c.type_));
1217            let mut def = format!("{} {}", quote(&c.name), typ);
1218            if !c.nullable {
1219                def.push_str(" NOT NULL");
1220            }
1221            if let Some(ref d) = c.default {
1222                def.push_str(" DEFAULT ");
1223                match d {
1224                    ColumnDefaultConfig::Literal(s) => def.push_str(s),
1225                    ColumnDefaultConfig::Expression { expression } => def.push_str(expression),
1226                }
1227            }
1228            col_defs.push(def);
1229        }
1230        let cfg_col_names: HashSet<&str> = cols.iter().map(|c| c.name.as_str()).collect();
1231        // Note: compute_migration_plan is a pure DDL-generation function; timestamp strings are
1232        // embedded in the DDL output for display/execution. We use postgres-compatible strings
1233        // here since the plan is always applied to a real DB via execute_migration_plan which
1234        // uses the dialect there. If dialect-awareness is needed here in future, pass dialect in.
1235        for (name, suf) in [
1236            ("created_at", "TIMESTAMPTZ NOT NULL DEFAULT NOW()"),
1237            ("updated_at", "TIMESTAMPTZ NOT NULL DEFAULT NOW()"),
1238            ("archived_at", "TIMESTAMPTZ"),
1239            ("created_by", "TEXT"),
1240            ("updated_by", "TEXT"),
1241        ] {
1242            if !cfg_col_names.contains(name) {
1243                col_defs.push(format!("{} {}", quote(name), suf));
1244            }
1245        }
1246        let pk_cols = match &new_table.primary_key {
1247            PrimaryKeyConfig::Single(s) => vec![quote(s)],
1248            PrimaryKeyConfig::Composite(v) => v.iter().map(|s| quote(s)).collect(),
1249        };
1250        col_defs.push(format!("PRIMARY KEY ({})", pk_cols.join(", ")));
1251        for u in &new_table.unique {
1252            col_defs.push(format!(
1253                "UNIQUE ({})",
1254                u.iter().map(|s| quote(s)).collect::<Vec<_>>().join(", ")
1255            ));
1256        }
1257        for ch in &new_table.check {
1258            col_defs.push(format!(
1259                "CONSTRAINT {} CHECK ({})",
1260                quote(&ch.name),
1261                ch.expression
1262            ));
1263        }
1264
1265        steps.push(MigrationStep {
1266            step: 0,
1267            operation: MigrationOperation::CreateTable,
1268            schema: schema.clone(),
1269            table: Some(new_table.name.clone()),
1270            object: new_table.name.clone(),
1271            object_type: "table".into(),
1272            description: format!("Create table \"{}\".\"{}\"", schema, new_table.name),
1273            ddl: Some(format!(
1274                "CREATE TABLE IF NOT EXISTS {} (\n  {}\n)",
1275                full,
1276                col_defs.join(",\n  ")
1277            )),
1278            safety: MigrationSafety::Safe,
1279            risk: MigrationRisk::None,
1280            risk_detail: None,
1281        });
1282        if new_table.audit_log {
1283            let audit_ddl = audit_table_ddl(&schema, &new_table.name, cols, dialect);
1284            steps.push(MigrationStep {
1285                step: 0,
1286                operation: MigrationOperation::CreateTable,
1287                schema: schema.clone(),
1288                table: Some(format!("{}_audit", new_table.name)),
1289                object: format!("{}_audit", new_table.name),
1290                object_type: "table".into(),
1291                description: format!(
1292                    "Create audit table \"{}\".\"{}_audit\"",
1293                    schema, new_table.name
1294                ),
1295                ddl: Some(audit_ddl),
1296                safety: MigrationSafety::Safe,
1297                risk: MigrationRisk::None,
1298                risk_detail: None,
1299            });
1300        }
1301        if new_table.versioning.as_ref().is_some_and(|v| v.enabled) {
1302            let pk_col = match &new_table.primary_key {
1303                PrimaryKeyConfig::Single(s) => s.clone(),
1304                PrimaryKeyConfig::Composite(v) => v[0].clone(),
1305            };
1306            // Split history DDL into CREATE TABLE and index
1307            let history_create = format!(
1308                "CREATE TABLE IF NOT EXISTS {}.{} (\n  {}\n)",
1309                quote(&schema),
1310                quote(&format!("{}_history", new_table.name)),
1311                {
1312                    let full_ddl =
1313                        history_table_ddl(&schema, &new_table.name, &pk_col, cols, dialect);
1314                    full_ddl
1315                        .lines()
1316                        .skip(1) // skip CREATE TABLE line
1317                        .take_while(|l| !l.trim_start().starts_with("-- index:"))
1318                        .collect::<Vec<_>>()
1319                        .join("\n")
1320                        .trim_end_matches(['\n', ',', ')'])
1321                        .to_string()
1322                        + "\n)"
1323                }
1324            );
1325            steps.push(MigrationStep {
1326                step: 0,
1327                operation: MigrationOperation::CreateTable,
1328                schema: schema.clone(),
1329                table: Some(format!("{}_history", new_table.name)),
1330                object: format!("{}_history", new_table.name),
1331                object_type: "table".into(),
1332                description: format!(
1333                    "Create history table \"{}\".\"{}_history\" (versioning)",
1334                    schema, new_table.name
1335                ),
1336                ddl: Some(history_create),
1337                safety: MigrationSafety::Safe,
1338                risk: MigrationRisk::None,
1339                risk_detail: None,
1340            });
1341            steps.push(MigrationStep {
1342                step: 0,
1343                operation: MigrationOperation::CreateIndex,
1344                schema: schema.clone(),
1345                table: Some(format!("{}_history", new_table.name)),
1346                object: format!("{}_history_{}_idx", new_table.name, pk_col),
1347                object_type: "index".into(),
1348                description: format!(
1349                    "Create index on history table \"{}\".\"{}\" ({pk_col}, _version DESC)",
1350                    schema, new_table.name
1351                ),
1352                ddl: Some(history_index_ddl(&schema, &new_table.name, &pk_col)),
1353                safety: MigrationSafety::Safe,
1354                risk: MigrationRisk::None,
1355                risk_detail: None,
1356            });
1357        }
1358    }
1359
1360    // Existing tables that gained audit_log or versioning
1361    for new_table in &new.tables {
1362        if added_table_ids.contains(new_table.id.as_str()) {
1363            continue;
1364        }
1365        if let Some(old_table) = old_tables.get(new_table.id.as_str()) {
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 cols = cols_by_table
1369                .get(new_table.id.as_str())
1370                .map(|v| v.as_slice())
1371                .unwrap_or(&[]);
1372
1373            if !old_table.audit_log && new_table.audit_log {
1374                let audit_ddl = audit_table_ddl(&schema, &new_table.name, cols, dialect);
1375                steps.push(MigrationStep {
1376                    step: 0,
1377                    operation: MigrationOperation::CreateTable,
1378                    schema: schema.clone(),
1379                    table: Some(format!("{}_audit", new_table.name)),
1380                    object: format!("{}_audit", new_table.name),
1381                    object_type: "table".into(),
1382                    description: format!(
1383                        "Enable audit log: create \"{}\".\"{}_audit\"",
1384                        schema, new_table.name
1385                    ),
1386                    ddl: Some(audit_ddl),
1387                    safety: MigrationSafety::Safe,
1388                    risk: MigrationRisk::None,
1389                    risk_detail: None,
1390                });
1391            }
1392
1393            let old_versioning_enabled = old_table.versioning.as_ref().is_some_and(|v| v.enabled);
1394            let new_versioning_enabled = new_table.versioning.as_ref().is_some_and(|v| v.enabled);
1395            if !old_versioning_enabled && new_versioning_enabled {
1396                let pk_col = match &new_table.primary_key {
1397                    PrimaryKeyConfig::Single(s) => s.clone(),
1398                    PrimaryKeyConfig::Composite(v) => v[0].clone(),
1399                };
1400                let history_ddl =
1401                    history_table_ddl(&schema, &new_table.name, &pk_col, cols, dialect);
1402                let create_only = history_ddl
1403                    .lines()
1404                    .take_while(|l| !l.trim_start().starts_with("-- index:"))
1405                    .collect::<Vec<_>>()
1406                    .join("\n");
1407                steps.push(MigrationStep {
1408                    step: 0,
1409                    operation: MigrationOperation::CreateTable,
1410                    schema: schema.clone(),
1411                    table: Some(format!("{}_history", new_table.name)),
1412                    object: format!("{}_history", new_table.name),
1413                    object_type: "table".into(),
1414                    description: format!(
1415                        "Enable versioning: create \"{}\".\"{}_history\"",
1416                        schema, new_table.name
1417                    ),
1418                    ddl: Some(create_only.trim().to_string()),
1419                    safety: MigrationSafety::Safe,
1420                    risk: MigrationRisk::None,
1421                    risk_detail: None,
1422                });
1423                steps.push(MigrationStep {
1424                    step: 0,
1425                    operation: MigrationOperation::CreateIndex,
1426                    schema: schema.clone(),
1427                    table: Some(format!("{}_history", new_table.name)),
1428                    object: format!("{}_history_{}_idx", new_table.name, pk_col),
1429                    object_type: "index".into(),
1430                    description: format!(
1431                        "Create history index on \"{}\".\"{}\"",
1432                        schema, new_table.name
1433                    ),
1434                    ddl: Some(history_index_ddl(&schema, &new_table.name, &pk_col)),
1435                    safety: MigrationSafety::Safe,
1436                    risk: MigrationRisk::None,
1437                    risk_detail: None,
1438                });
1439            }
1440        }
1441    }
1442
1443    for old_table in &old.tables {
1444        if !new_tables.contains_key(old_table.id.as_str()) {
1445            let sid = old_table.schema_id.as_deref().unwrap_or(default_old_sid);
1446            let schema = schema_name_for(sid, &old_schemas);
1447            steps.push(MigrationStep {
1448                step: 0,
1449                operation: MigrationOperation::DropTable,
1450                schema: schema.clone(),
1451                table: Some(old_table.name.clone()),
1452                object: old_table.name.clone(),
1453                object_type: "table".into(),
1454                description: format!("Table \"{}\".\"{}\" removed from config", schema, old_table.name),
1455                ddl: None,
1456                safety: MigrationSafety::WarnOnly,
1457                risk: MigrationRisk::ManualActionRequired,
1458                risk_detail: Some("Table NOT dropped from database (data safety). Run DROP TABLE manually if intended.".into()),
1459            });
1460        }
1461    }
1462
1463    // ── 4. Column changes for existing tables ────────────────────────────────
1464    for new_col in &new.columns {
1465        if added_table_ids.contains(new_col.table_id.as_str()) {
1466            continue;
1467        }
1468        let table = match new_tables.get(new_col.table_id.as_str()) {
1469            Some(t) => t,
1470            None => continue,
1471        };
1472        let sid = table.schema_id.as_deref().unwrap_or(default_new_sid);
1473        let schema = schema_name_for(sid, &new_schemas);
1474        let full = format!("{}.{}", quote(&schema), quote(&table.name));
1475
1476        if let Some(old_col) = old_columns.get(new_col.id.as_str()) {
1477            if old_col.table_id != new_col.table_id {
1478                steps.push(MigrationStep {
1479                    step: 0,
1480                    operation: MigrationOperation::AddColumn,
1481                    schema: schema.clone(),
1482                    table: Some(table.name.clone()),
1483                    object: new_col.name.clone(),
1484                    object_type: "column".into(),
1485                    description: format!("Column \"{}\" (id: {}) appears to have moved tables — manual migration required", new_col.name, new_col.id),
1486                    ddl: None,
1487                    safety: MigrationSafety::WarnOnly,
1488                    risk: MigrationRisk::ManualActionRequired,
1489                    risk_detail: Some(format!("Cannot automate column move from table {} to {}.", old_col.table_id, new_col.table_id)),
1490                });
1491                continue;
1492            }
1493
1494            // Rename
1495            if old_col.name != new_col.name {
1496                steps.push(MigrationStep {
1497                    step: 0,
1498                    operation: MigrationOperation::RenameColumn,
1499                    schema: schema.clone(),
1500                    table: Some(table.name.clone()),
1501                    object: new_col.name.clone(),
1502                    object_type: "column".into(),
1503                    description: format!(
1504                        "Rename column \"{}\" → \"{}\" on \"{}\".\"{}\"",
1505                        old_col.name, new_col.name, schema, table.name
1506                    ),
1507                    ddl: Some(format!(
1508                        "ALTER TABLE {} RENAME COLUMN {} TO {}",
1509                        full,
1510                        quote(&old_col.name),
1511                        quote(&new_col.name)
1512                    )),
1513                    safety: MigrationSafety::Safe,
1514                    risk: MigrationRisk::None,
1515                    risk_detail: None,
1516                });
1517                steps.extend(companion_column_steps(
1518                    &schema,
1519                    table,
1520                    &CompanionColumnOp::Rename {
1521                        old: &old_col.name,
1522                        new: &new_col.name,
1523                    },
1524                ));
1525            }
1526
1527            // Type change
1528            let old_type = dialect.ddl_type(&parse_canonical(&old_col.type_));
1529            let new_type = dialect.ddl_type(&parse_canonical(&new_col.type_));
1530            if old_type.to_uppercase() != new_type.to_uppercase() {
1531                let col_name = &new_col.name;
1532                steps.push(MigrationStep {
1533                    step: 0,
1534                    operation: MigrationOperation::AlterColumnType,
1535                    schema: schema.clone(),
1536                    table: Some(table.name.clone()),
1537                    object: col_name.clone(),
1538                    object_type: "column".into(),
1539                    description: format!("Change type of \"{}\".\"{}\".\"{}\": {} → {}", schema, table.name, col_name, old_type, new_type),
1540                    ddl: Some(format!("ALTER TABLE {} ALTER COLUMN {} TYPE {} USING {}::{}", full, quote(col_name), new_type, quote(col_name), new_type)),
1541                    safety: MigrationSafety::BestEffort,
1542                    risk: MigrationRisk::MayFail,
1543                    risk_detail: Some(format!("USING {}::{} cast may fail for incompatible values. Provide a custom USING expression if needed.", col_name, new_type)),
1544                });
1545                steps.extend(companion_column_steps(
1546                    &schema,
1547                    table,
1548                    &CompanionColumnOp::AlterType {
1549                        name: col_name.as_str(),
1550                        ty: &new_type,
1551                    },
1552                ));
1553            }
1554
1555            // Nullability: nullable → NOT NULL
1556            if old_col.nullable && !new_col.nullable {
1557                if let Some(ref d) = new_col.default {
1558                    let default_val = default_str(d);
1559                    // Backfill NULLs first using the configured default
1560                    steps.push(MigrationStep {
1561                        step: 0,
1562                        operation: MigrationOperation::BackfillNulls,
1563                        schema: schema.clone(),
1564                        table: Some(table.name.clone()),
1565                        object: new_col.name.clone(),
1566                        object_type: "column".into(),
1567                        description: format!("Backfill NULLs in \"{}\".\"{}\".\"{}\": SET {} = {} WHERE {} IS NULL", schema, table.name, new_col.name, new_col.name, default_val, new_col.name),
1568                        ddl: Some(format!("UPDATE {} SET {} = {} WHERE {} IS NULL", full, quote(&new_col.name), default_val, quote(&new_col.name))),
1569                        safety: MigrationSafety::Safe,
1570                        risk: MigrationRisk::DataWillBeModified,
1571                        risk_detail: Some(format!("Existing NULLs in column \"{}\" will be set to {} before NOT NULL is enforced.", new_col.name, default_val)),
1572                    });
1573                    // Then set NOT NULL — safe because NULLs are gone
1574                    steps.push(MigrationStep {
1575                        step: 0,
1576                        operation: MigrationOperation::SetNotNull,
1577                        schema: schema.clone(),
1578                        table: Some(table.name.clone()),
1579                        object: new_col.name.clone(),
1580                        object_type: "column".into(),
1581                        description: format!("Set NOT NULL on \"{}\".\"{}\".\"{}\": NULLs pre-filled with default ({})", schema, table.name, new_col.name, default_val),
1582                        ddl: Some(format!("ALTER TABLE {} ALTER COLUMN {} SET NOT NULL", full, quote(&new_col.name))),
1583                        safety: MigrationSafety::Safe,
1584                        risk: MigrationRisk::None,
1585                        risk_detail: None,
1586                    });
1587                } else {
1588                    // No default — best effort; will fail if NULLs exist
1589                    steps.push(MigrationStep {
1590                        step: 0,
1591                        operation: MigrationOperation::SetNotNull,
1592                        schema: schema.clone(),
1593                        table: Some(table.name.clone()),
1594                        object: new_col.name.clone(),
1595                        object_type: "column".into(),
1596                        description: format!("Set NOT NULL on \"{}\".\"{}\".\"{}\": no default configured — will fail if NULLs exist", schema, table.name, new_col.name),
1597                        ddl: Some(format!("ALTER TABLE {} ALTER COLUMN {} SET NOT NULL", full, quote(&new_col.name))),
1598                        safety: MigrationSafety::BestEffort,
1599                        risk: MigrationRisk::ExistingNullsMustBeAbsent,
1600                        risk_detail: Some(format!(
1601                            "No default value configured for column \"{}\". Add a default to the config to enable automatic NULL backfill before enforcing NOT NULL.",
1602                            new_col.name
1603                        )),
1604                    });
1605                }
1606            }
1607
1608            // Nullability: NOT NULL → nullable
1609            if !old_col.nullable && new_col.nullable {
1610                steps.push(MigrationStep {
1611                    step: 0,
1612                    operation: MigrationOperation::DropNotNull,
1613                    schema: schema.clone(),
1614                    table: Some(table.name.clone()),
1615                    object: new_col.name.clone(),
1616                    object_type: "column".into(),
1617                    description: format!(
1618                        "Drop NOT NULL on \"{}\".\"{}\".\"{}\": column becomes nullable",
1619                        schema, table.name, new_col.name
1620                    ),
1621                    ddl: Some(format!(
1622                        "ALTER TABLE {} ALTER COLUMN {} DROP NOT NULL",
1623                        full,
1624                        quote(&new_col.name)
1625                    )),
1626                    safety: MigrationSafety::Safe,
1627                    risk: MigrationRisk::None,
1628                    risk_detail: None,
1629                });
1630            }
1631
1632            // Default change
1633            let old_def = old_col.default.as_ref().map(default_str);
1634            let new_def = new_col.default.as_ref().map(default_str);
1635            if old_def != new_def {
1636                match &new_col.default {
1637                    Some(d) => {
1638                        let val = default_str(d);
1639                        steps.push(MigrationStep {
1640                            step: 0,
1641                            operation: MigrationOperation::SetDefault,
1642                            schema: schema.clone(),
1643                            table: Some(table.name.clone()),
1644                            object: new_col.name.clone(),
1645                            object_type: "column".into(),
1646                            description: format!(
1647                                "Set DEFAULT {} on \"{}\".\"{}\".\"{}\": was {}",
1648                                val,
1649                                schema,
1650                                table.name,
1651                                new_col.name,
1652                                old_def.as_deref().unwrap_or("none")
1653                            ),
1654                            ddl: Some(format!(
1655                                "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {}",
1656                                full,
1657                                quote(&new_col.name),
1658                                val
1659                            )),
1660                            safety: MigrationSafety::Safe,
1661                            risk: MigrationRisk::None,
1662                            risk_detail: None,
1663                        });
1664                    }
1665                    None => {
1666                        steps.push(MigrationStep {
1667                            step: 0,
1668                            operation: MigrationOperation::DropDefault,
1669                            schema: schema.clone(),
1670                            table: Some(table.name.clone()),
1671                            object: new_col.name.clone(),
1672                            object_type: "column".into(),
1673                            description: format!(
1674                                "Drop DEFAULT on \"{}\".\"{}\".\"{}\": was {}",
1675                                schema,
1676                                table.name,
1677                                new_col.name,
1678                                old_def.as_deref().unwrap_or("none")
1679                            ),
1680                            ddl: Some(format!(
1681                                "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT",
1682                                full,
1683                                quote(&new_col.name)
1684                            )),
1685                            safety: MigrationSafety::Safe,
1686                            risk: MigrationRisk::None,
1687                            risk_detail: None,
1688                        });
1689                    }
1690                }
1691            }
1692        } else {
1693            // New column: ADD COLUMN
1694            let new_type = dialect.ddl_type(&parse_canonical(&new_col.type_));
1695            let mut col_def = format!("{} {}", quote(&new_col.name), new_type);
1696            if !new_col.nullable {
1697                col_def.push_str(" NOT NULL");
1698            }
1699            if let Some(ref d) = new_col.default {
1700                col_def.push_str(" DEFAULT ");
1701                match d {
1702                    ColumnDefaultConfig::Literal(s) => col_def.push_str(s),
1703                    ColumnDefaultConfig::Expression { expression } => col_def.push_str(expression),
1704                }
1705            }
1706            steps.push(MigrationStep {
1707                step: 0,
1708                operation: MigrationOperation::AddColumn,
1709                schema: schema.clone(),
1710                table: Some(table.name.clone()),
1711                object: new_col.name.clone(),
1712                object_type: "column".into(),
1713                description: format!(
1714                    "Add column \"{}\" {} to \"{}\".\"{}\"",
1715                    new_col.name, new_type, schema, table.name
1716                ),
1717                ddl: Some(format!("ALTER TABLE {} ADD COLUMN {}", full, col_def)),
1718                safety: MigrationSafety::Safe,
1719                risk: MigrationRisk::None,
1720                risk_detail: None,
1721            });
1722            steps.extend(companion_column_steps(
1723                &schema,
1724                table,
1725                &CompanionColumnOp::Add {
1726                    name: &new_col.name,
1727                    ty: &new_type,
1728                },
1729            ));
1730        }
1731    }
1732
1733    // Removed columns (warn only)
1734    for old_col in &old.columns {
1735        if new.columns.iter().any(|c| c.id == old_col.id) {
1736            continue;
1737        }
1738        if !new_tables.contains_key(old_col.table_id.as_str()) {
1739            continue;
1740        }
1741        let table_name = old_tables
1742            .get(old_col.table_id.as_str())
1743            .map(|t| t.name.as_str())
1744            .unwrap_or(&old_col.table_id);
1745        let sid = old_tables
1746            .get(old_col.table_id.as_str())
1747            .and_then(|t| t.schema_id.as_deref())
1748            .unwrap_or(default_old_sid);
1749        let schema = schema_name_for(sid, &old_schemas);
1750        steps.push(MigrationStep {
1751            step: 0,
1752            operation: MigrationOperation::DropColumn,
1753            schema: schema.clone(),
1754            table: Some(table_name.to_string()),
1755            object: old_col.name.clone(),
1756            object_type: "column".into(),
1757            description: format!("Column \"{}\" removed from config on \"{}\".\"{}\"", old_col.name, schema, table_name),
1758            ddl: None,
1759            safety: MigrationSafety::WarnOnly,
1760            risk: MigrationRisk::ManualActionRequired,
1761            risk_detail: Some("Column NOT dropped from database (data safety). Run ALTER TABLE DROP COLUMN manually if intended.".into()),
1762        });
1763    }
1764
1765    // ── 5. Indexes ───────────────────────────────────────────────────────────
1766    for old_idx in &old.indexes {
1767        if !new_indexes.contains_key(old_idx.id.as_str()) {
1768            let sid = old_idx.schema_id.as_deref().unwrap_or(default_old_sid);
1769            let schema = schema_name_for(sid, &old_schemas);
1770            steps.push(MigrationStep {
1771                step: 0,
1772                operation: MigrationOperation::DropIndex,
1773                schema: schema.clone(),
1774                table: old_tables
1775                    .get(old_idx.table_id.as_str())
1776                    .map(|t| t.name.clone()),
1777                object: old_idx.name.clone(),
1778                object_type: "index".into(),
1779                description: format!("Drop index \"{}\" in schema \"{}\"", old_idx.name, schema),
1780                ddl: Some(format!(
1781                    "DROP INDEX IF EXISTS {}.{}",
1782                    quote(&schema),
1783                    quote(&old_idx.name)
1784                )),
1785                safety: MigrationSafety::Safe,
1786                risk: MigrationRisk::None,
1787                risk_detail: None,
1788            });
1789        }
1790    }
1791    for new_idx in &new.indexes {
1792        if old_indexes.contains_key(new_idx.id.as_str())
1793            || added_table_ids.contains(new_idx.table_id.as_str())
1794        {
1795            continue;
1796        }
1797        let sid = new_idx.schema_id.as_deref().unwrap_or(default_new_sid);
1798        let schema = match new_schemas.get(sid) {
1799            Some(s) => schema_override.unwrap_or(&s.name).to_string(),
1800            None => continue,
1801        };
1802        let table = match new_tables.get(new_idx.table_id.as_str()) {
1803            Some(t) => t,
1804            None => continue,
1805        };
1806        let full_table = format!("{}.{}", quote(&schema), quote(&table.name));
1807        let mut col_parts: Vec<String> = Vec::new();
1808        for col in &new_idx.columns {
1809            match col {
1810                IndexColumnEntry::Name(n) => col_parts.push(quote(n)),
1811                IndexColumnEntry::Spec {
1812                    name, direction, ..
1813                } => {
1814                    let dir = direction
1815                        .as_deref()
1816                        .map(|d| format!(" {}", d.to_uppercase()))
1817                        .unwrap_or_default();
1818                    col_parts.push(format!("{}{}", quote(name), dir));
1819                }
1820                IndexColumnEntry::Expression { expression } => col_parts.push(expression.clone()),
1821            }
1822        }
1823        let method = new_idx.method.as_deref().unwrap_or("btree");
1824        let unique_kw = if new_idx.unique { "UNIQUE " } else { "" };
1825        let include = if new_idx.include.is_empty() {
1826            String::new()
1827        } else {
1828            format!(
1829                " INCLUDE ({})",
1830                new_idx
1831                    .include
1832                    .iter()
1833                    .map(|s| quote(s))
1834                    .collect::<Vec<_>>()
1835                    .join(", ")
1836            )
1837        };
1838        let where_clause = new_idx
1839            .where_
1840            .as_ref()
1841            .map(|w| format!(" WHERE {}", w))
1842            .unwrap_or_default();
1843        steps.push(MigrationStep {
1844            step: 0,
1845            operation: MigrationOperation::CreateIndex,
1846            schema: schema.clone(),
1847            table: Some(table.name.clone()),
1848            object: new_idx.name.clone(),
1849            object_type: "index".into(),
1850            description: format!(
1851                "Create {}index \"{}\" on \"{}\".\"{}\"",
1852                if new_idx.unique { "unique " } else { "" },
1853                new_idx.name,
1854                schema,
1855                table.name
1856            ),
1857            ddl: Some(format!(
1858                "CREATE {}INDEX IF NOT EXISTS {} ON {} USING {} ({}){}{}",
1859                unique_kw,
1860                quote(&new_idx.name),
1861                full_table,
1862                method,
1863                col_parts.join(", "),
1864                include,
1865                where_clause
1866            )),
1867            safety: MigrationSafety::Safe,
1868            risk: MigrationRisk::None,
1869            risk_detail: None,
1870        });
1871    }
1872
1873    // ── 6. Foreign keys ──────────────────────────────────────────────────────
1874    for old_rel in &old.relationships {
1875        if !new_rels.contains_key(old_rel.id.as_str()) {
1876            let from_sid_fallback = old_rel.from_schema_id.as_deref().unwrap_or(default_old_sid);
1877            let from_schema = old_schemas
1878                .get(from_sid_fallback)
1879                .map(|s| s.name.as_str())
1880                .unwrap_or(from_sid_fallback);
1881            let from_table = old_tables
1882                .get(old_rel.from_table_id.as_str())
1883                .map(|t| t.name.as_str())
1884                .unwrap_or(&old_rel.from_table_id);
1885            let constraint = old_rel.name.as_deref().unwrap_or(&old_rel.id);
1886            let schema_q = quote(schema_override.unwrap_or(from_schema));
1887            steps.push(MigrationStep {
1888                step: 0,
1889                operation: MigrationOperation::DropForeignKey,
1890                schema: schema_override.unwrap_or(from_schema).to_string(),
1891                table: Some(from_table.to_string()),
1892                object: constraint.to_string(),
1893                object_type: "foreign_key".into(),
1894                description: format!(
1895                    "Drop FK \"{}\" from \"{}\".\"{}\"",
1896                    constraint,
1897                    schema_override.unwrap_or(from_schema),
1898                    from_table
1899                ),
1900                ddl: Some(format!(
1901                    "ALTER TABLE {}.{} DROP CONSTRAINT IF EXISTS {}",
1902                    schema_q,
1903                    quote(from_table),
1904                    quote(constraint)
1905                )),
1906                safety: MigrationSafety::Safe,
1907                risk: MigrationRisk::None,
1908                risk_detail: None,
1909            });
1910        }
1911    }
1912    for new_rel in &new.relationships {
1913        if old_rels.contains_key(new_rel.id.as_str())
1914            || added_table_ids.contains(new_rel.from_table_id.as_str())
1915            || added_table_ids.contains(new_rel.to_table_id.as_str())
1916        {
1917            continue;
1918        }
1919        let from_sid = new_rel.from_schema_id.as_deref().unwrap_or(default_new_sid);
1920        let from_schema = match new_schemas.get(from_sid) {
1921            Some(s) => s,
1922            None => continue,
1923        };
1924        let from_table = match new_tables.get(new_rel.from_table_id.as_str()) {
1925            Some(t) => t,
1926            None => continue,
1927        };
1928        let from_col = new
1929            .columns
1930            .iter()
1931            .find(|c| c.id == new_rel.from_column_id)
1932            .map(|c| c.name.clone())
1933            .unwrap_or_else(|| new_rel.from_column_id.clone());
1934
1935        // Resolve the target side — cross-package or same-package.
1936        let (to_schema_name, to_table_name, to_col) =
1937            if let Some(pkg_id) = new_rel.to_package_id.as_deref() {
1938                match cross_package_configs.get(pkg_id) {
1939                    Some(foreign) => {
1940                        let foreign_tables: HashMap<_, _> =
1941                            foreign.tables.iter().map(|t| (t.id.as_str(), t)).collect();
1942                        let foreign_schemas: HashMap<_, _> =
1943                            foreign.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
1944                        let foreign_default_sid =
1945                            foreign.schemas.first().map(|s| s.id.as_str()).unwrap_or("");
1946                        let to_sid = new_rel
1947                            .to_schema_id
1948                            .as_deref()
1949                            .unwrap_or(foreign_default_sid);
1950                        let tbl = match foreign_tables.get(new_rel.to_table_id.as_str()) {
1951                            Some(t) => t,
1952                            None => continue,
1953                        };
1954                        let schema = match foreign_schemas.get(to_sid) {
1955                            Some(s) => s,
1956                            None => continue,
1957                        };
1958                        let col = foreign
1959                            .columns
1960                            .iter()
1961                            .find(|c| c.id == new_rel.to_column_id)
1962                            .map(|c| c.name.clone())
1963                            .unwrap_or_else(|| new_rel.to_column_id.clone());
1964                        (schema.name.clone(), tbl.name.clone(), col)
1965                    }
1966                    None => continue,
1967                }
1968            } else {
1969                let to_sid = new_rel.to_schema_id.as_deref().unwrap_or(default_new_sid);
1970                let to_schema = match new_schemas.get(to_sid) {
1971                    Some(s) => s,
1972                    None => continue,
1973                };
1974                let to_table = match new_tables.get(new_rel.to_table_id.as_str()) {
1975                    Some(t) => t,
1976                    None => continue,
1977                };
1978                let col = new
1979                    .columns
1980                    .iter()
1981                    .find(|c| c.id == new_rel.to_column_id)
1982                    .map(|c| c.name.clone())
1983                    .unwrap_or_else(|| new_rel.to_column_id.clone());
1984                (
1985                    schema_override.unwrap_or(&to_schema.name).to_string(),
1986                    to_table.name.clone(),
1987                    col,
1988                )
1989            };
1990
1991        let from_schema_str = schema_override.unwrap_or(&from_schema.name);
1992        let from_q = format!("{}.{}", quote(from_schema_str), quote(&from_table.name));
1993        let to_q = format!("{}.{}", quote(&to_schema_name), quote(&to_table_name));
1994        let constraint = new_rel.name.as_deref().unwrap_or(&new_rel.id);
1995        let on_update = new_rel.on_update.as_deref().unwrap_or("NO ACTION");
1996        let on_delete = new_rel.on_delete.as_deref().unwrap_or("NO ACTION");
1997        steps.push(MigrationStep {
1998            step: 0,
1999            operation: MigrationOperation::AddForeignKey,
2000            schema: from_schema_str.to_string(),
2001            table: Some(from_table.name.clone()),
2002            object: constraint.to_string(),
2003            object_type: "foreign_key".into(),
2004            description: format!(
2005                "Add FK \"{}\" on \"{}\".\"{}\" → \"{}\".\"{}\"",
2006                constraint, from_schema_str, from_table.name, to_schema_name, to_table_name
2007            ),
2008            ddl: Some(format!(
2009                "ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({}) ON UPDATE {} ON DELETE {}",
2010                from_q, quote(constraint), quote(&from_col), to_q, quote(&to_col), on_update, on_delete
2011            )),
2012            safety: MigrationSafety::BestEffort,
2013            risk: MigrationRisk::None,
2014            risk_detail: Some("PostgreSQL has no ADD CONSTRAINT IF NOT EXISTS; ignored if constraint already exists.".into()),
2015        });
2016    }
2017
2018    // Assign sequential step numbers
2019    for (i, s) in steps.iter_mut().enumerate() {
2020        s.step = i + 1;
2021    }
2022
2023    Ok(MigrationPlan { steps })
2024}
2025
2026// ─── execute_migration_plan ──────────────────────────────────────────────────
2027
2028/// Execute a pre-computed `MigrationPlan` against the tenant database.
2029/// Writes per-step audit records to the config (architect) database.
2030/// Returns counts and any warning messages collected from best-effort failures.
2031#[allow(clippy::too_many_arguments)]
2032pub async fn execute_migration_plan(
2033    migration_pool: &Pool,
2034    config_pool: &Pool,
2035    plan: &MigrationPlan,
2036    migration_plan_id: &str,
2037    package_id: &str,
2038    tenant_id: &str,
2039    from_version: Option<&str>,
2040    to_version: &str,
2041) -> Result<MigrationExecutionResult, AppError> {
2042    let mut applied = 0usize;
2043    let mut warned = 0usize;
2044    let mut warnings: Vec<String> = Vec::new();
2045
2046    for step in &plan.steps {
2047        let op = step.operation.to_string();
2048        let safety_str = format!("{:?}", step.safety);
2049        let risk_str = format!("{:?}", step.risk);
2050
2051        match step.safety {
2052            MigrationSafety::WarnOnly => {
2053                let msg = step
2054                    .risk_detail
2055                    .clone()
2056                    .unwrap_or_else(|| step.description.clone());
2057                tracing::warn!(step = step.step, %op, "migration plan warning (no DDL)");
2058                warnings.push(format!("[Step {}] {}", step.step, msg));
2059                let _ = crate::store::insert_migration_audit(
2060                    config_pool,
2061                    migration_plan_id,
2062                    package_id,
2063                    tenant_id,
2064                    from_version,
2065                    to_version,
2066                    step.step as i32,
2067                    &op,
2068                    &step.schema,
2069                    step.table.as_deref(),
2070                    &step.object,
2071                    &step.object_type,
2072                    &step.description,
2073                    step.ddl.as_deref(),
2074                    &safety_str,
2075                    &risk_str,
2076                    "skipped",
2077                    None,
2078                )
2079                .await;
2080                warned += 1;
2081            }
2082            MigrationSafety::Safe | MigrationSafety::BestEffort => {
2083                if let Some(ref sql) = step.ddl {
2084                    tracing::info!(step = step.step, %op, %sql, "executing migration step");
2085                    match sqlx::query(sql).execute(migration_pool).await {
2086                        Ok(_) => {
2087                            let _ = crate::store::insert_migration_audit(
2088                                config_pool,
2089                                migration_plan_id,
2090                                package_id,
2091                                tenant_id,
2092                                from_version,
2093                                to_version,
2094                                step.step as i32,
2095                                &op,
2096                                &step.schema,
2097                                step.table.as_deref(),
2098                                &step.object,
2099                                &step.object_type,
2100                                &step.description,
2101                                step.ddl.as_deref(),
2102                                &safety_str,
2103                                &risk_str,
2104                                "applied",
2105                                None,
2106                            )
2107                            .await;
2108                            applied += 1;
2109                        }
2110                        Err(e) => {
2111                            let err_str = e.to_string();
2112                            if matches!(step.safety, MigrationSafety::BestEffort) {
2113                                tracing::warn!(step = step.step, %op, error = %e, "migration step failed (best-effort, continuing)");
2114                                let msg = format!(
2115                                    "[Step {}] {} — Error: {}",
2116                                    step.step, step.description, err_str
2117                                );
2118                                warnings.push(msg);
2119                                let _ = crate::store::insert_migration_audit(
2120                                    config_pool,
2121                                    migration_plan_id,
2122                                    package_id,
2123                                    tenant_id,
2124                                    from_version,
2125                                    to_version,
2126                                    step.step as i32,
2127                                    &op,
2128                                    &step.schema,
2129                                    step.table.as_deref(),
2130                                    &step.object,
2131                                    &step.object_type,
2132                                    &step.description,
2133                                    step.ddl.as_deref(),
2134                                    &safety_str,
2135                                    &risk_str,
2136                                    "warned",
2137                                    Some(&err_str),
2138                                )
2139                                .await;
2140                                warned += 1;
2141                            } else {
2142                                let _ = crate::store::insert_migration_audit(
2143                                    config_pool,
2144                                    migration_plan_id,
2145                                    package_id,
2146                                    tenant_id,
2147                                    from_version,
2148                                    to_version,
2149                                    step.step as i32,
2150                                    &op,
2151                                    &step.schema,
2152                                    step.table.as_deref(),
2153                                    &step.object,
2154                                    &step.object_type,
2155                                    &step.description,
2156                                    step.ddl.as_deref(),
2157                                    &safety_str,
2158                                    &risk_str,
2159                                    "failed",
2160                                    Some(&err_str),
2161                                )
2162                                .await;
2163                                return Err(AppError::Db(e));
2164                            }
2165                        }
2166                    }
2167                }
2168            }
2169        }
2170    }
2171
2172    Ok(MigrationExecutionResult {
2173        applied,
2174        warned,
2175        warnings,
2176    })
2177}
2178
2179/// Build CREATE TABLE DDL for the `{table}_history` companion table used by row versioning.
2180/// All source columns are replicated with their types but as nullable and without any constraints
2181/// (no NOT NULL, no UNIQUE, no FK, no CHECK). Five versioning metadata columns are prepended.
2182pub fn history_table_ddl(
2183    schema_name: &str,
2184    table_name: &str,
2185    pk_col: &str,
2186    source_cols: &[&ColumnConfig],
2187    dialect: &dyn Dialect,
2188) -> String {
2189    let history_name = format!("{}_history", table_name);
2190    let history_full = format!("{}.{}", quote(schema_name), quote(&history_name));
2191
2192    let mut col_defs: Vec<String> = Vec::new();
2193    col_defs.push(format!(
2194        "{} {} NOT NULL DEFAULT {}",
2195        quote("_history_id"),
2196        "UUID",
2197        dialect.uuid_default_expr()
2198    ));
2199    col_defs.push(format!("{} BIGINT NOT NULL", quote("_version")));
2200    col_defs.push(format!("{} TEXT NOT NULL", quote("_operation")));
2201    col_defs.push(format!(
2202        "{} {} NOT NULL DEFAULT {}",
2203        quote("_recorded_at"),
2204        dialect.audit_timestamp_type(),
2205        dialect.now_fn()
2206    ));
2207    col_defs.push(format!(
2208        "{} {}",
2209        quote("_valid_from"),
2210        dialect.audit_timestamp_type()
2211    ));
2212    col_defs.push(format!(
2213        "{} {}",
2214        quote("_valid_to"),
2215        dialect.audit_timestamp_type()
2216    ));
2217
2218    let config_col_names: HashSet<&str> = source_cols.iter().map(|c| c.name.as_str()).collect();
2219    for c in source_cols {
2220        let typ = dialect.ddl_type(&parse_canonical(&c.type_));
2221        col_defs.push(format!("{} {}", quote(&c.name), typ));
2222    }
2223    let audit_ts = dialect.audit_timestamp_type();
2224    for (name, typ) in [
2225        ("created_at", audit_ts),
2226        ("updated_at", audit_ts),
2227        ("archived_at", audit_ts),
2228        ("created_by", "TEXT"),
2229        ("updated_by", "TEXT"),
2230    ] {
2231        if !config_col_names.contains(name) {
2232            col_defs.push(format!("{} {}", quote(name), typ));
2233        }
2234    }
2235    col_defs.push(format!("PRIMARY KEY ({})", quote("_history_id")));
2236
2237    let history_full_quoted = format!("{}.{}", quote(schema_name), quote(&history_name));
2238    let idx_sql = format!(
2239        "-- index: CREATE INDEX IF NOT EXISTS {} ON {} ({}, {})",
2240        quote(&format!("{}_history_{}_idx", table_name, pk_col)),
2241        history_full_quoted,
2242        quote(pk_col),
2243        quote("_version")
2244    );
2245
2246    format!(
2247        "CREATE TABLE IF NOT EXISTS {} (\n  {}\n)\n{}",
2248        history_full,
2249        col_defs.join(",\n  "),
2250        idx_sql
2251    )
2252}
2253
2254/// Build just the index DDL for the `{table}_history` table (separate from CREATE TABLE).
2255fn history_index_ddl(schema_name: &str, table_name: &str, pk_col: &str) -> String {
2256    format!(
2257        "CREATE INDEX IF NOT EXISTS {} ON {}.{} ({}, {} DESC)",
2258        quote(&format!("{}_history_{}_idx", table_name, pk_col)),
2259        quote(schema_name),
2260        quote(&format!("{}_history", table_name)),
2261        quote(pk_col),
2262        quote("_version")
2263    )
2264}
2265
2266/// A structural column change that must be mirrored onto companion (`_audit`/`_history`) tables.
2267enum CompanionColumnOp<'a> {
2268    /// A new column was added to the source table.
2269    Add { name: &'a str, ty: &'a str },
2270    /// A source column was renamed.
2271    Rename { old: &'a str, new: &'a str },
2272    /// A source column's type changed.
2273    AlterType { name: &'a str, ty: &'a str },
2274}
2275
2276/// Companion-table suffixes that are enabled for a table (`audit`, `history`).
2277fn enabled_companion_suffixes(table: &TableConfig) -> Vec<&'static str> {
2278    let mut suffixes = Vec::new();
2279    if table.audit_log {
2280        suffixes.push("audit");
2281    }
2282    if table.versioning.as_ref().is_some_and(|v| v.enabled) {
2283        suffixes.push("history");
2284    }
2285    suffixes
2286}
2287
2288/// Generate ALTER steps that keep the `{table}_audit` / `{table}_history` companion tables in
2289/// schema-sync with their source table when a column is added, renamed, or retyped.
2290///
2291/// Companion tables replicate source columns as **nullable with no constraints**, so only
2292/// structural changes propagate here. Nullability and default changes on the source column are
2293/// intentionally NOT mirrored — companion rows are historical snapshots that must stay nullable.
2294fn companion_column_steps(
2295    schema: &str,
2296    table: &TableConfig,
2297    op: &CompanionColumnOp<'_>,
2298) -> Vec<MigrationStep> {
2299    let mut steps = Vec::new();
2300    for suffix in enabled_companion_suffixes(table) {
2301        let companion = format!("{}_{}", table.name, suffix);
2302        let full = format!("{}.{}", quote(schema), quote(&companion));
2303        let (operation, object, ddl, description, safety, risk, risk_detail) = match op {
2304            CompanionColumnOp::Add { name, ty } => (
2305                MigrationOperation::AddColumn,
2306                name.to_string(),
2307                // IF NOT EXISTS guards against collisions with synthetic columns the companion
2308                // table may already carry (e.g. created_at/updated_by).
2309                format!(
2310                    "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} {}",
2311                    full,
2312                    quote(name),
2313                    ty
2314                ),
2315                format!(
2316                    "Sync {} table: add column \"{}\" to \"{}\".\"{}\"",
2317                    suffix, name, schema, companion
2318                ),
2319                MigrationSafety::Safe,
2320                MigrationRisk::None,
2321                None,
2322            ),
2323            CompanionColumnOp::Rename { old, new } => (
2324                MigrationOperation::RenameColumn,
2325                new.to_string(),
2326                format!(
2327                    "ALTER TABLE {} RENAME COLUMN {} TO {}",
2328                    full,
2329                    quote(old),
2330                    quote(new)
2331                ),
2332                format!(
2333                    "Sync {} table: rename column \"{}\" → \"{}\" on \"{}\".\"{}\"",
2334                    suffix, old, new, schema, companion
2335                ),
2336                MigrationSafety::Safe,
2337                MigrationRisk::None,
2338                None,
2339            ),
2340            CompanionColumnOp::AlterType { name, ty } => (
2341                MigrationOperation::AlterColumnType,
2342                name.to_string(),
2343                format!(
2344                    "ALTER TABLE {} ALTER COLUMN {} TYPE {} USING {}::{}",
2345                    full,
2346                    quote(name),
2347                    ty,
2348                    quote(name),
2349                    ty
2350                ),
2351                format!(
2352                    "Sync {} table: change type of \"{}\".\"{}\".\"{}\" → {}",
2353                    suffix, schema, companion, name, ty
2354                ),
2355                MigrationSafety::BestEffort,
2356                MigrationRisk::MayFail,
2357                Some(format!(
2358                    "USING {}::{} cast may fail for incompatible values in the {} table.",
2359                    name, ty, suffix
2360                )),
2361            ),
2362        };
2363        steps.push(MigrationStep {
2364            step: 0,
2365            operation,
2366            schema: schema.to_string(),
2367            table: Some(companion.clone()),
2368            object,
2369            object_type: "column".into(),
2370            description,
2371            ddl: Some(ddl),
2372            safety,
2373            risk,
2374            risk_detail,
2375        });
2376    }
2377    steps
2378}
2379
2380/// Build CREATE TABLE DDL for the `{table}_audit` companion table.
2381/// All source columns are replicated as nullable with no constraints, plus five audit metadata
2382/// columns prepended: audit_id (PK), audit_action, audit_at, audit_by, changed_fields.
2383fn audit_table_ddl(
2384    schema_name: &str,
2385    table_name: &str,
2386    source_cols: &[&ColumnConfig],
2387    dialect: &dyn Dialect,
2388) -> String {
2389    let audit_name = format!("{}_audit", table_name);
2390    let audit_full = format!("{}.{}", quote(schema_name), quote(&audit_name));
2391
2392    let mut col_defs: Vec<String> = Vec::new();
2393    col_defs.push(format!(
2394        "{} {} NOT NULL DEFAULT {}",
2395        quote("audit_id"),
2396        "UUID",
2397        dialect.uuid_default_expr()
2398    ));
2399    col_defs.push(format!("{} TEXT NOT NULL", quote("audit_action")));
2400    col_defs.push(format!(
2401        "{} {} NOT NULL DEFAULT {}",
2402        quote("audit_at"),
2403        dialect.audit_timestamp_type(),
2404        dialect.now_fn()
2405    ));
2406    col_defs.push(format!("{} TEXT", quote("audit_by")));
2407    col_defs.push(format!(
2408        "{} {}",
2409        quote("changed_fields"),
2410        dialect.sys_json_type()
2411    ));
2412
2413    let config_col_names: HashSet<&str> = source_cols.iter().map(|c| c.name.as_str()).collect();
2414    for c in source_cols {
2415        let typ = dialect.ddl_type(&parse_canonical(&c.type_));
2416        col_defs.push(format!("{} {}", quote(&c.name), typ));
2417    }
2418    let audit_ts = dialect.audit_timestamp_type();
2419    for (name, typ) in [
2420        ("created_at", audit_ts),
2421        ("updated_at", audit_ts),
2422        ("archived_at", audit_ts),
2423        ("created_by", "TEXT"),
2424        ("updated_by", "TEXT"),
2425    ] {
2426        if !config_col_names.contains(name) {
2427            col_defs.push(format!("{} {}", quote(name), typ));
2428        }
2429    }
2430    col_defs.push(format!("PRIMARY KEY ({})", quote("audit_id")));
2431
2432    format!(
2433        "CREATE TABLE IF NOT EXISTS {} (\n  {}\n)",
2434        audit_full,
2435        col_defs.join(",\n  ")
2436    )
2437}
2438
2439#[cfg(test)]
2440mod enum_recreate_tests {
2441    use super::*;
2442
2443    fn schema(id: &str, name: &str) -> SchemaConfig {
2444        SchemaConfig {
2445            id: id.into(),
2446            name: name.into(),
2447            comment: None,
2448        }
2449    }
2450
2451    fn table(id: &str, name: &str, schema_id: &str) -> TableConfig {
2452        TableConfig {
2453            id: id.into(),
2454            schema_id: Some(schema_id.into()),
2455            name: name.into(),
2456            comment: None,
2457            primary_key: PrimaryKeyConfig::Single("id".into()),
2458            unique: vec![],
2459            check: vec![],
2460            audit_log: false,
2461            versioning: None,
2462            global: false,
2463        }
2464    }
2465
2466    fn col(id: &str, table_id: &str, name: &str, ty: &str, default: Option<&str>) -> ColumnConfig {
2467        ColumnConfig {
2468            id: id.into(),
2469            table_id: table_id.into(),
2470            name: name.into(),
2471            type_: ColumnTypeConfig::Simple(ty.into()),
2472            nullable: true,
2473            default: default.map(|d| ColumnDefaultConfig::Literal(d.into())),
2474            comment: None,
2475            asset: None,
2476            extensible: false,
2477        }
2478    }
2479
2480    fn enum_cfg(id: &str, name: &str, schema_id: &str, values: &[&str]) -> EnumConfig {
2481        EnumConfig {
2482            id: id.into(),
2483            schema_id: Some(schema_id.into()),
2484            name: name.into(),
2485            values: values.iter().map(|s| s.to_string()).collect(),
2486            comment: None,
2487        }
2488    }
2489
2490    fn ddls(steps: &[MigrationStep]) -> Vec<String> {
2491        steps.iter().filter_map(|s| s.ddl.clone()).collect()
2492    }
2493
2494    #[test]
2495    fn finds_scalar_and_array_dependent_columns() {
2496        let mut cfg = FullConfig::default();
2497        cfg.schemas = vec![schema("s1", "app")];
2498        cfg.tables = vec![table("t_orders", "orders", "s1")];
2499        cfg.columns = vec![
2500            col(
2501                "c1",
2502                "t_orders",
2503                "status",
2504                "order_status",
2505                Some("'pending'"),
2506            ),
2507            col("c2", "t_orders", "tags", "order_status[]", None),
2508            col("c3", "t_orders", "name", "text", None), // not an enum
2509        ];
2510        let e = enum_cfg("e1", "order_status", "s1", &["pending", "shipped"]);
2511        let new_tables: HashMap<&str, &TableConfig> =
2512            cfg.tables.iter().map(|t| (t.id.as_str(), t)).collect();
2513        let new_schemas: HashMap<&str, &SchemaConfig> =
2514            cfg.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
2515
2516        let deps = enum_dependent_columns(&e, &cfg, &new_tables, &new_schemas, None);
2517        assert_eq!(deps.len(), 2);
2518        let scalar = deps.iter().find(|d| d.column == "status").unwrap();
2519        assert_eq!(scalar.schema, "app");
2520        assert_eq!(scalar.table, "orders");
2521        assert!(!scalar.is_array);
2522        assert_eq!(scalar.default.as_deref(), Some("'pending'"));
2523        let arr = deps.iter().find(|d| d.column == "tags").unwrap();
2524        assert!(arr.is_array);
2525        assert!(arr.default.is_none());
2526    }
2527
2528    #[test]
2529    fn recreate_sequence_emits_rename_create_recast_drop() {
2530        let e = enum_cfg("e1", "order_status", "s1", &["pending", "shipped"]);
2531        let deps = vec![
2532            EnumColumnRef {
2533                schema: "app".into(),
2534                table: "orders".into(),
2535                column: "status".into(),
2536                default: Some("'pending'".into()),
2537                is_array: false,
2538            },
2539            EnumColumnRef {
2540                schema: "app".into(),
2541                table: "orders".into(),
2542                column: "tags".into(),
2543                default: None,
2544                is_array: true,
2545            },
2546        ];
2547        let mut steps = Vec::new();
2548        recreate_enum_steps(&mut steps, "app", &e, &["cancelled"], &deps);
2549        let sql = ddls(&steps);
2550
2551        // Leading informational step carries no DDL.
2552        assert!(matches!(steps[0].safety, MigrationSafety::WarnOnly));
2553        assert!(steps[0].ddl.is_none());
2554
2555        // Rename → create → (drop default, recast, set default) → recast array → drop old.
2556        assert_eq!(
2557            sql[0],
2558            r#"ALTER TYPE "app"."order_status" RENAME TO "order_status__arch_old""#
2559        );
2560        assert_eq!(
2561            sql[1],
2562            r#"CREATE TYPE "app"."order_status" AS ENUM ('pending', 'shipped')"#
2563        );
2564        assert_eq!(
2565            sql[2],
2566            r#"ALTER TABLE "app"."orders" ALTER COLUMN "status" DROP DEFAULT"#
2567        );
2568        assert_eq!(
2569            sql[3],
2570            r#"ALTER TABLE "app"."orders" ALTER COLUMN "status" TYPE "app"."order_status" USING "status"::text::"app"."order_status""#
2571        );
2572        assert_eq!(
2573            sql[4],
2574            r#"ALTER TABLE "app"."orders" ALTER COLUMN "status" SET DEFAULT 'pending'"#
2575        );
2576        // Array column: no default, single recast with array casts.
2577        assert_eq!(
2578            sql[5],
2579            r#"ALTER TABLE "app"."orders" ALTER COLUMN "tags" TYPE "app"."order_status"[] USING "tags"::text[]::"app"."order_status"[]"#
2580        );
2581        assert_eq!(
2582            *sql.last().unwrap(),
2583            r#"DROP TYPE IF EXISTS "app"."order_status__arch_old""#
2584        );
2585    }
2586}
2587
2588#[cfg(all(test, feature = "sqlite"))]
2589mod companion_sync_tests {
2590    use super::*;
2591    use crate::db::sqlite::SqliteDialect;
2592
2593    fn schema(id: &str, name: &str) -> SchemaConfig {
2594        SchemaConfig {
2595            id: id.into(),
2596            name: name.into(),
2597            comment: None,
2598        }
2599    }
2600
2601    fn table(id: &str, name: &str, audit: bool, versioning: bool) -> TableConfig {
2602        TableConfig {
2603            id: id.into(),
2604            schema_id: Some("s1".into()),
2605            name: name.into(),
2606            comment: None,
2607            primary_key: PrimaryKeyConfig::Single("id".into()),
2608            unique: vec![],
2609            check: vec![],
2610            audit_log: audit,
2611            versioning: versioning.then(|| VersioningConfig {
2612                enabled: true,
2613                keep_versions: None,
2614            }),
2615            global: false,
2616        }
2617    }
2618
2619    fn col(id: &str, name: &str, ty: &str) -> ColumnConfig {
2620        ColumnConfig {
2621            id: id.into(),
2622            table_id: "t1".into(),
2623            name: name.into(),
2624            type_: ColumnTypeConfig::Simple(ty.into()),
2625            nullable: true,
2626            default: None,
2627            comment: None,
2628            asset: None,
2629            extensible: false,
2630        }
2631    }
2632
2633    fn base(audit: bool, versioning: bool) -> FullConfig {
2634        let mut cfg = FullConfig::default();
2635        cfg.schemas = vec![schema("s1", "app")];
2636        cfg.tables = vec![table("t1", "orders", audit, versioning)];
2637        cfg.columns = vec![col("c0", "id", "uuid"), col("c1", "status", "text")];
2638        cfg
2639    }
2640
2641    fn plan(old: &FullConfig, new: &FullConfig) -> Vec<String> {
2642        let dialect = SqliteDialect;
2643        compute_migration_plan(old, new, None, None, &dialect, &HashMap::new())
2644            .unwrap()
2645            .steps
2646            .into_iter()
2647            .filter_map(|s| s.ddl)
2648            .collect()
2649    }
2650
2651    #[test]
2652    fn add_column_syncs_audit_and_history() {
2653        let old = base(true, true);
2654        let mut new = base(true, true);
2655        new.columns.push(col("c2", "note", "text"));
2656
2657        let sql = plan(&old, &new);
2658        assert!(sql
2659            .iter()
2660            .any(|s| s == r#"ALTER TABLE "app"."orders" ADD COLUMN "note" TEXT"#));
2661        assert!(sql.iter().any(
2662            |s| s == r#"ALTER TABLE "app"."orders_audit" ADD COLUMN IF NOT EXISTS "note" TEXT"#
2663        ));
2664        assert!(sql
2665            .iter()
2666            .any(|s| s
2667                == r#"ALTER TABLE "app"."orders_history" ADD COLUMN IF NOT EXISTS "note" TEXT"#));
2668    }
2669
2670    #[test]
2671    fn rename_column_syncs_companions() {
2672        let old = base(true, true);
2673        let mut new = base(true, true);
2674        new.columns[1].name = "state".into();
2675
2676        let sql = plan(&old, &new);
2677        assert!(sql
2678            .iter()
2679            .any(|s| s == r#"ALTER TABLE "app"."orders_audit" RENAME COLUMN "status" TO "state""#));
2680        assert!(sql.iter().any(
2681            |s| s == r#"ALTER TABLE "app"."orders_history" RENAME COLUMN "status" TO "state""#
2682        ));
2683    }
2684
2685    #[test]
2686    fn alter_type_syncs_companions() {
2687        let old = base(true, false);
2688        let mut new = base(true, false);
2689        new.columns[1].type_ = ColumnTypeConfig::Simple("integer".into());
2690
2691        let sql = plan(&old, &new);
2692        assert!(sql.iter().any(|s| s
2693            == r#"ALTER TABLE "app"."orders_audit" ALTER COLUMN "status" TYPE INTEGER USING "status"::INTEGER"#));
2694        // versioning disabled → no history sync
2695        assert!(!sql.iter().any(|s| s.contains("orders_history")));
2696    }
2697
2698    #[test]
2699    fn no_companion_steps_when_features_disabled() {
2700        let old = base(false, false);
2701        let mut new = base(false, false);
2702        new.columns.push(col("c2", "note", "text"));
2703
2704        let sql = plan(&old, &new);
2705        assert!(sql
2706            .iter()
2707            .any(|s| s == r#"ALTER TABLE "app"."orders" ADD COLUMN "note" TEXT"#));
2708        assert!(!sql.iter().any(|s| s.contains("orders_audit")));
2709        assert!(!sql.iter().any(|s| s.contains("orders_history")));
2710    }
2711
2712    #[test]
2713    fn nullability_change_does_not_touch_companions() {
2714        let old = base(true, true);
2715        let mut new = base(true, true);
2716        new.columns[1].nullable = false;
2717
2718        let sql = plan(&old, &new);
2719        // Main table gets SET NOT NULL, companions are untouched (snapshots stay nullable).
2720        assert!(sql.iter().any(|s| s.contains("SET NOT NULL")));
2721        assert!(!sql.iter().any(|s| s.contains("orders_audit")));
2722        assert!(!sql.iter().any(|s| s.contains("orders_history")));
2723    }
2724}