Skip to main content

reddb_server/runtime/
impl_migrations.rs

1//! Native migration execution: CREATE / APPLY / ROLLBACK / EXPLAIN MIGRATION
2
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use super::*;
8use crate::application::migration_collections as mc;
9use crate::application::migration_graph;
10use crate::application::migration_inference;
11use crate::application::vcs::{Author, CreateCommitInput};
12use crate::storage::query::ast::{
13    ApplyMigrationQuery, ApplyMigrationTarget, CreateMigrationQuery, ExplainMigrationQuery,
14    RollbackMigrationQuery,
15};
16use crate::storage::unified::entity::{EntityData, EntityId, EntityKind, RowData, UnifiedEntity};
17
18fn now_ms() -> i64 {
19    SystemTime::now()
20        .duration_since(UNIX_EPOCH)
21        .map(|d| d.as_millis() as i64)
22        .unwrap_or(0)
23}
24
25fn val_text(v: &Value) -> Option<&str> {
26    if let Value::Text(s) = v {
27        Some(s.as_ref())
28    } else {
29        None
30    }
31}
32
33fn val_bool(v: &Value) -> Option<bool> {
34    if let Value::Boolean(b) = v {
35        Some(*b)
36    } else {
37        None
38    }
39}
40
41fn migration_author(rt: &RedDBRuntime) -> Author {
42    let store = rt.inner.db.store();
43    let name = store
44        .get_config("red.vcs.author.name")
45        .and_then(|v| {
46            if let Value::Text(s) = v {
47                Some(s.to_string())
48            } else {
49                None
50            }
51        })
52        .unwrap_or_else(|| "reddb".to_string());
53    let email = store
54        .get_config("red.vcs.author.email")
55        .and_then(|v| {
56            if let Value::Text(s) = v {
57                Some(s.to_string())
58            } else {
59                None
60            }
61        })
62        .unwrap_or_else(|| "reddb@localhost".to_string());
63    Author { name, email }
64}
65
66fn insert_meta_row(
67    store: &UnifiedStore,
68    collection: &str,
69    fields: HashMap<String, Value>,
70) -> RedDBResult<EntityId> {
71    let _ = store.get_or_create_collection(collection);
72    store
73        .insert_auto(
74            collection,
75            UnifiedEntity::new(
76                EntityId::new(0),
77                EntityKind::TableRow {
78                    table: Arc::from(collection),
79                    row_id: 0,
80                },
81                EntityData::Row(RowData {
82                    columns: Vec::new(),
83                    named: Some(fields),
84                    schema: None,
85                }),
86            ),
87        )
88        .map_err(|e| RedDBError::Internal(e.to_string()))
89}
90
91/// Find a migration row by name. Returns the entity and its named fields.
92fn find_migration(
93    store: &UnifiedStore,
94    name: &str,
95) -> Option<(UnifiedEntity, HashMap<String, Value>)> {
96    let manager = store.get_collection(mc::MIGRATIONS)?;
97    let results = manager.query_all(|entity| {
98        if let EntityData::Row(ref row) = entity.data {
99            if let Some(ref named) = row.named {
100                return named.get("name").and_then(|v| val_text(v)) == Some(name);
101            }
102        }
103        false
104    });
105    results.into_iter().find_map(|entity| {
106        if let EntityData::Row(ref row) = entity.data {
107            if let Some(ref named) = row.named {
108                return Some((entity.clone(), named.clone()));
109            }
110        }
111        None
112    })
113}
114
115/// Update a field on an existing migration row (by name).
116fn update_migration_field(
117    store: &UnifiedStore,
118    name: &str,
119    key: &str,
120    value: Value,
121) -> RedDBResult<()> {
122    let manager = store
123        .get_collection(mc::MIGRATIONS)
124        .ok_or_else(|| RedDBError::Internal("red_migrations collection not found".to_string()))?;
125    let results = manager.query_all(|entity| {
126        if let EntityData::Row(ref row) = entity.data {
127            if let Some(ref named) = row.named {
128                return named.get("name").and_then(|v| val_text(v)) == Some(name);
129            }
130        }
131        false
132    });
133    for mut entity in results {
134        if let EntityData::Row(ref mut row) = entity.data {
135            if let Some(ref mut named) = row.named {
136                named.insert(key.to_string(), value.clone());
137                manager
138                    .update(entity)
139                    .map_err(|e| RedDBError::Internal(e.to_string()))?;
140                return Ok(());
141            }
142        }
143    }
144    Err(RedDBError::NotFound(format!(
145        "migration '{name}' not found"
146    )))
147}
148
149/// Load all dependency edges from red_migration_deps.
150fn load_all_edges(store: &UnifiedStore) -> Vec<(String, String)> {
151    let Some(manager) = store.get_collection(mc::MIGRATION_DEPS) else {
152        return Vec::new();
153    };
154    manager
155        .query_all(|_| true)
156        .into_iter()
157        .filter_map(|entity| {
158            if let EntityData::Row(ref row) = entity.data {
159                if let Some(ref named) = row.named {
160                    let from = named
161                        .get("migration_id")
162                        .and_then(|v| val_text(v))?
163                        .to_string();
164                    let to = named
165                        .get("depends_on_id")
166                        .and_then(|v| val_text(v))?
167                        .to_string();
168                    return Some((from, to));
169                }
170            }
171            None
172        })
173        .collect()
174}
175
176/// Load all migration names from red_migrations.
177fn load_all_migration_names(store: &UnifiedStore) -> Vec<String> {
178    let Some(manager) = store.get_collection(mc::MIGRATIONS) else {
179        return Vec::new();
180    };
181    manager
182        .query_all(|_| true)
183        .into_iter()
184        .filter_map(|entity| {
185            if let EntityData::Row(ref row) = entity.data {
186                if let Some(ref named) = row.named {
187                    return named
188                        .get("name")
189                        .and_then(|v| val_text(v))
190                        .map(|s| s.to_string());
191                }
192            }
193            None
194        })
195        .collect()
196}
197
198impl RedDBRuntime {
199    /// CREATE MIGRATION — register a migration definition with status=pending.
200    pub fn execute_create_migration(
201        &self,
202        raw_query: &str,
203        q: &CreateMigrationQuery,
204    ) -> RedDBResult<RuntimeQueryResult> {
205        let store_arc = self.inner.db.store();
206        let store: &UnifiedStore = &store_arc;
207
208        if find_migration(store, &q.name).is_some() {
209            return Err(RedDBError::Query(format!(
210                "migration '{}' already exists",
211                q.name
212            )));
213        }
214
215        for dep in &q.depends_on {
216            if find_migration(store, dep).is_none() {
217                return Err(RedDBError::Query(format!(
218                    "migration '{dep}' referenced in DEPENDS ON does not exist"
219                )));
220            }
221        }
222
223        // Cycle detection: check that adding name → dep edges wouldn't create a cycle.
224        let existing_edges = load_all_edges(store);
225        for dep in &q.depends_on {
226            if migration_graph::would_create_cycle(&existing_edges, q.name.as_str(), dep) {
227                return Err(RedDBError::Query(format!(
228                    "adding DEPENDS ON '{dep}' to migration '{}' would create a dependency cycle",
229                    q.name
230                )));
231            }
232        }
233
234        let mut fields: HashMap<String, Value> = HashMap::new();
235        fields.insert("name".to_string(), Value::text(q.name.as_str()));
236        fields.insert("status".to_string(), Value::text("pending"));
237        fields.insert(
238            "kind".to_string(),
239            Value::text(if q.batch_size.is_some() {
240                "data"
241            } else {
242                "schema"
243            }),
244        );
245        fields.insert("body".to_string(), Value::text(q.body.as_str()));
246        fields.insert(
247            "author".to_string(),
248            Value::text(migration_author(self).name.as_str()),
249        );
250        fields.insert("created_at".to_string(), Value::TimestampMs(now_ms()));
251        fields.insert("applied_at".to_string(), Value::Null);
252        fields.insert("rows_total".to_string(), Value::Null);
253        fields.insert("rows_processed".to_string(), Value::UnsignedInteger(0));
254        fields.insert("vcs_commit_hash".to_string(), Value::Null);
255        fields.insert("no_rollback".to_string(), Value::Boolean(q.no_rollback));
256        fields.insert(
257            "batch_size".to_string(),
258            q.batch_size
259                .map(Value::UnsignedInteger)
260                .unwrap_or(Value::Null),
261        );
262        insert_meta_row(store, mc::MIGRATIONS, fields)?;
263
264        for dep in &q.depends_on {
265            let mut dep_fields: HashMap<String, Value> = HashMap::new();
266            dep_fields.insert("migration_id".to_string(), Value::text(q.name.as_str()));
267            dep_fields.insert("depends_on_id".to_string(), Value::text(dep.as_str()));
268            dep_fields.insert("inferred".to_string(), Value::Boolean(false));
269            insert_meta_row(store, mc::MIGRATION_DEPS, dep_fields)?;
270        }
271
272        // Auto-infer additional dependency edges from static analysis of the body.
273        let existing_migrations: Vec<(String, String)> = store
274            .get_collection(mc::MIGRATIONS)
275            .map(|manager| {
276                manager
277                    .query_all(|_| true)
278                    .into_iter()
279                    .filter_map(|entity| {
280                        if let EntityData::Row(ref row) = entity.data {
281                            if let Some(ref named) = row.named {
282                                let name = named.get("name").and_then(|v| val_text(v))?.to_string();
283                                let body = named.get("body").and_then(|v| val_text(v))?.to_string();
284                                return Some((name, body));
285                            }
286                        }
287                        None
288                    })
289                    .collect()
290            })
291            .unwrap_or_default();
292        let explicit_deps: std::collections::HashSet<String> =
293            q.depends_on.iter().cloned().collect();
294        let inferred_edges = migration_inference::infer_dependencies(
295            q.name.as_str(),
296            q.body.as_str(),
297            &existing_migrations,
298        );
299        for (_, dep) in inferred_edges {
300            if explicit_deps.contains(&dep) {
301                continue; // already stored as explicit
302            }
303            // Only store if it wouldn't create a cycle (re-check with updated edge set).
304            let current_edges = load_all_edges(store);
305            if !migration_graph::would_create_cycle(&current_edges, q.name.as_str(), &dep) {
306                let mut dep_fields: HashMap<String, Value> = HashMap::new();
307                dep_fields.insert("migration_id".to_string(), Value::text(q.name.as_str()));
308                dep_fields.insert("depends_on_id".to_string(), Value::text(dep.as_str()));
309                dep_fields.insert("inferred".to_string(), Value::Boolean(true));
310                let _ = insert_meta_row(store, mc::MIGRATION_DEPS, dep_fields);
311            }
312        }
313
314        Ok(RuntimeQueryResult::ok_message(
315            raw_query.to_string(),
316            &format!("migration '{}' registered (pending)", q.name),
317            "create_migration",
318        ))
319    }
320
321    /// APPLY MIGRATION name [FOR TENANT id] | APPLY MIGRATION * [FOR TENANT id]
322    pub fn execute_apply_migration(
323        &self,
324        raw_query: &str,
325        q: &ApplyMigrationQuery,
326    ) -> RedDBResult<RuntimeQueryResult> {
327        // FOR TENANT * fans out to every known tenant.
328        if let Some(tenant) = &q.for_tenant {
329            if tenant == "*" {
330                return self.apply_migration_all_tenants(raw_query, q);
331            }
332            // FOR TENANT <specific_id>: set tenant context for this apply.
333            crate::runtime::impl_core::set_current_tenant(tenant.clone());
334        }
335
336        let result = match &q.target {
337            ApplyMigrationTarget::Named(name) => self.apply_single_migration(raw_query, name),
338            ApplyMigrationTarget::All => self.apply_all_pending(raw_query),
339        };
340
341        // Clear tenant override after apply so it doesn't leak.
342        if q.for_tenant.is_some() {
343            crate::runtime::impl_core::clear_current_tenant();
344        }
345
346        result
347    }
348
349    fn apply_all_pending(&self, raw_query: &str) -> RedDBResult<RuntimeQueryResult> {
350        let store_arc = self.inner.db.store();
351        let store: &UnifiedStore = &store_arc;
352        let pending = self.collect_pending_migrations(store);
353        if pending.is_empty() {
354            return Ok(RuntimeQueryResult::ok_message(
355                raw_query.to_string(),
356                "no pending migrations",
357                "apply_migration",
358            ));
359        }
360        let mut applied = 0u32;
361        let mut messages: Vec<String> = Vec::new();
362        for name in pending {
363            match self.apply_single_migration(raw_query, &name) {
364                Ok(_) => {
365                    applied += 1;
366                    messages.push(format!("applied: {name}"));
367                }
368                Err(e) => {
369                    messages.push(format!("failed: {name} — {e}"));
370                    break;
371                }
372            }
373        }
374        let summary = messages.join("; ");
375        Ok(RuntimeQueryResult::ok_message(
376            raw_query.to_string(),
377            &format!("applied {applied} migration(s): {summary}"),
378            "apply_migration",
379        ))
380    }
381
382    /// Fan out APPLY MIGRATION * to every known tenant in the auth store.
383    fn apply_migration_all_tenants(
384        &self,
385        raw_query: &str,
386        q: &ApplyMigrationQuery,
387    ) -> RedDBResult<RuntimeQueryResult> {
388        let tenant_ids = self.list_known_tenants();
389        if tenant_ids.is_empty() {
390            return Ok(RuntimeQueryResult::ok_message(
391                raw_query.to_string(),
392                "no tenants found — nothing applied",
393                "apply_migration",
394            ));
395        }
396        let mut results: Vec<String> = Vec::new();
397        for tenant in &tenant_ids {
398            crate::runtime::impl_core::set_current_tenant(tenant.clone());
399            let inner_q = ApplyMigrationQuery {
400                target: q.target.clone(),
401                for_tenant: None,
402            };
403            match self.execute_apply_migration(raw_query, &inner_q) {
404                Ok(r) => results.push(format!(
405                    "tenant={tenant}: {}",
406                    r.result
407                        .records
408                        .first()
409                        .and_then(|rec| rec.get("message"))
410                        .and_then(|v| val_text(v))
411                        .unwrap_or("ok")
412                )),
413                Err(e) => results.push(format!("tenant={tenant}: error — {e}")),
414            }
415            crate::runtime::impl_core::clear_current_tenant();
416        }
417        Ok(RuntimeQueryResult::ok_message(
418            raw_query.to_string(),
419            &results.join("; "),
420            "apply_migration",
421        ))
422    }
423
424    /// Collect distinct tenant IDs from the auth store.
425    fn list_known_tenants(&self) -> Vec<String> {
426        let auth_store = match self.inner.auth_store.read().clone() {
427            Some(s) => s,
428            None => return Vec::new(),
429        };
430        let users = auth_store.list_users_scoped(None);
431        let mut tenants: std::collections::HashSet<String> = std::collections::HashSet::new();
432        for u in users {
433            if let Some(ref t) = u.tenant_id {
434                tenants.insert(t.clone());
435            }
436        }
437        let mut out: Vec<String> = tenants.into_iter().collect();
438        out.sort();
439        out
440    }
441
442    fn collect_pending_migrations(&self, store: &UnifiedStore) -> Vec<String> {
443        // Collect only pending migrations.
444        let Some(manager) = store.get_collection(mc::MIGRATIONS) else {
445            return Vec::new();
446        };
447        let pending: Vec<String> = manager
448            .query_all(|entity| {
449                if let EntityData::Row(ref row) = entity.data {
450                    if let Some(ref named) = row.named {
451                        return named.get("status").and_then(|v| val_text(v)) == Some("pending");
452                    }
453                }
454                false
455            })
456            .into_iter()
457            .filter_map(|entity| {
458                if let EntityData::Row(ref row) = entity.data {
459                    if let Some(ref named) = row.named {
460                        return named
461                            .get("name")
462                            .and_then(|v| val_text(v))
463                            .map(|s| s.to_string());
464                    }
465                }
466                None
467            })
468            .collect();
469
470        // Sort topologically using the full edge set (includes applied migrations
471        // as anchors — only pending nodes end up in the output).
472        let all_edges = load_all_edges(store);
473        // Filter edges to only those between pending migrations.
474        let pending_set: std::collections::HashSet<&str> =
475            pending.iter().map(|s| s.as_str()).collect();
476        let relevant_edges: Vec<(String, String)> = all_edges
477            .into_iter()
478            .filter(|(m, d)| pending_set.contains(m.as_str()) && pending_set.contains(d.as_str()))
479            .collect();
480
481        match migration_graph::topological_sort(&pending, &relevant_edges) {
482            Ok(sorted) => sorted,
483            Err(_) => pending, // cycle shouldn't happen (guarded at CREATE time); fall back
484        }
485    }
486
487    fn apply_single_migration(
488        &self,
489        raw_query: &str,
490        name: &str,
491    ) -> RedDBResult<RuntimeQueryResult> {
492        let store_arc = self.inner.db.store();
493        let store: &UnifiedStore = &store_arc;
494
495        let (_, fields) = find_migration(store, name)
496            .ok_or_else(|| RedDBError::NotFound(format!("migration '{name}' not found")))?;
497
498        let status = fields.get("status").and_then(|v| val_text(v)).unwrap_or("");
499
500        if status == "applied" {
501            return Ok(RuntimeQueryResult::ok_message(
502                raw_query.to_string(),
503                &format!("migration '{name}' is already applied"),
504                "apply_migration",
505            ));
506        }
507
508        // Verify all dependencies are applied.
509        let deps = self.load_migration_deps(store, name);
510        for dep in &deps {
511            match find_migration(store, dep) {
512                Some((_, dep_fields)) => {
513                    let dep_status = dep_fields
514                        .get("status")
515                        .and_then(|v| val_text(v))
516                        .unwrap_or("");
517                    if dep_status != "applied" {
518                        return Err(RedDBError::Query(format!(
519                            "migration '{name}' depends on '{dep}' which is not yet applied"
520                        )));
521                    }
522                }
523                None => {
524                    return Err(RedDBError::Query(format!(
525                        "migration '{name}' depends on '{dep}' which does not exist"
526                    )));
527                }
528            }
529        }
530
531        let body = fields
532            .get("body")
533            .and_then(|v| val_text(v))
534            .unwrap_or("")
535            .to_string();
536        let batch_size = match fields.get("batch_size") {
537            Some(Value::UnsignedInteger(n)) => Some(*n),
538            _ => None,
539        };
540        let no_rollback = fields
541            .get("no_rollback")
542            .and_then(val_bool)
543            .unwrap_or(false);
544        let rows_processed_start = match fields.get("rows_processed") {
545            Some(Value::UnsignedInteger(n)) => *n,
546            _ => 0,
547        };
548
549        let apply_result = if let Some(batch) = batch_size {
550            self.apply_batched(store, name, &body, batch, rows_processed_start)
551        } else {
552            self.apply_statements(name, &body)
553        };
554
555        match apply_result {
556            Err(e) => {
557                let err_msg = e.to_string();
558                let _ = update_migration_field(store, name, "status", Value::text("failed"));
559                let _ = update_migration_field(store, name, "error", Value::text(err_msg.as_str()));
560                Err(RedDBError::Query(format!(
561                    "migration '{name}' failed: {err_msg}"
562                )))
563            }
564            Ok(rows_processed) => {
565                let author = migration_author(self);
566                let commit_hash = match self.vcs_commit(CreateCommitInput {
567                    connection_id: 0,
568                    message: format!("migration: apply {name}"),
569                    author,
570                    committer: None,
571                    amend: false,
572                    allow_empty: true,
573                }) {
574                    Ok(commit) => commit.hash,
575                    Err(e) => {
576                        let err_msg = format!("VCS commit failed: {e}");
577                        let _ =
578                            update_migration_field(store, name, "status", Value::text("failed"));
579                        let _ = update_migration_field(
580                            store,
581                            name,
582                            "error",
583                            Value::text(err_msg.as_str()),
584                        );
585                        return Err(RedDBError::Query(format!(
586                            "migration '{name}' applied but {err_msg}"
587                        )));
588                    }
589                };
590
591                let _ = update_migration_field(store, name, "status", Value::text("applied"));
592                let _ =
593                    update_migration_field(store, name, "applied_at", Value::TimestampMs(now_ms()));
594                let _ = update_migration_field(
595                    store,
596                    name,
597                    "vcs_commit_hash",
598                    Value::text(commit_hash.as_str()),
599                );
600                if batch_size.is_some() {
601                    let _ = update_migration_field(
602                        store,
603                        name,
604                        "rows_processed",
605                        Value::UnsignedInteger(rows_processed),
606                    );
607                }
608                let msg = if no_rollback {
609                    format!(
610                        "migration '{name}' applied — {rows_processed} rows (no rollback, commit: {commit_hash})"
611                    )
612                } else {
613                    format!("migration '{name}' applied (commit: {commit_hash})")
614                };
615                Ok(RuntimeQueryResult::ok_message(
616                    raw_query.to_string(),
617                    &msg,
618                    "apply_migration",
619                ))
620            }
621        }
622    }
623
624    /// Execute a (possibly multi-statement) DDL body by splitting on `;`.
625    /// Returns Ok(0) — row count not tracked for DDL.
626    fn apply_statements(&self, name: &str, body: &str) -> RedDBResult<u64> {
627        let statements: Vec<&str> = body
628            .split(';')
629            .map(|s| s.trim())
630            .filter(|s| !s.is_empty())
631            .collect();
632        for stmt in statements {
633            self.execute_query(stmt).map_err(|e| {
634                RedDBError::Query(format!("statement in migration '{name}' failed: {e}"))
635            })?;
636        }
637        Ok(0)
638    }
639
640    /// Execute a data migration body in batches of `batch_size` rows,
641    /// persisting a checkpoint (`rows_processed`) after each batch.
642    /// Appends `LIMIT {batch_size}` to the body on each iteration;
643    /// stops when the engine reports fewer rows affected than the batch size.
644    fn apply_batched(
645        &self,
646        store: &UnifiedStore,
647        name: &str,
648        body: &str,
649        batch_size: u64,
650        initial_processed: u64,
651    ) -> RedDBResult<u64> {
652        let mut total = initial_processed;
653        loop {
654            let batch_body = format!("{body} LIMIT {batch_size}");
655            let result = self.execute_query(&batch_body).map_err(|e| {
656                RedDBError::Query(format!("batch in migration '{name}' failed: {e}"))
657            })?;
658            let affected = result.affected_rows;
659            total += affected;
660            // Persist checkpoint so a crash can resume from here.
661            let _ = update_migration_field(
662                store,
663                name,
664                "rows_processed",
665                Value::UnsignedInteger(total),
666            );
667            if affected < batch_size {
668                break;
669            }
670        }
671        Ok(total)
672    }
673
674    fn load_migration_deps(&self, store: &UnifiedStore, name: &str) -> Vec<String> {
675        let Some(manager) = store.get_collection(mc::MIGRATION_DEPS) else {
676            return Vec::new();
677        };
678        manager
679            .query_all(|entity| {
680                if let EntityData::Row(ref row) = entity.data {
681                    if let Some(ref named) = row.named {
682                        return named.get("migration_id").and_then(|v| val_text(v)) == Some(name);
683                    }
684                }
685                false
686            })
687            .into_iter()
688            .filter_map(|entity| {
689                if let EntityData::Row(ref row) = entity.data {
690                    if let Some(ref named) = row.named {
691                        return named
692                            .get("depends_on_id")
693                            .and_then(|v| val_text(v))
694                            .map(|s| s.to_string());
695                    }
696                }
697                None
698            })
699            .collect()
700    }
701
702    /// ROLLBACK MIGRATION name
703    pub fn execute_rollback_migration(
704        &self,
705        raw_query: &str,
706        q: &RollbackMigrationQuery,
707    ) -> RedDBResult<RuntimeQueryResult> {
708        let store_arc = self.inner.db.store();
709        let store: &UnifiedStore = &store_arc;
710
711        let (_, fields) = find_migration(store, &q.name)
712            .ok_or_else(|| RedDBError::NotFound(format!("migration '{}' not found", q.name)))?;
713
714        if fields
715            .get("no_rollback")
716            .and_then(val_bool)
717            .unwrap_or(false)
718        {
719            return Err(RedDBError::Query(format!(
720                "migration '{}' was declared NO ROLLBACK and cannot be rolled back",
721                q.name
722            )));
723        }
724
725        let status = fields.get("status").and_then(|v| val_text(v)).unwrap_or("");
726
727        if status != "applied" {
728            return Err(RedDBError::Query(format!(
729                "migration '{}' has status '{status}' — only applied migrations can be rolled back",
730                q.name
731            )));
732        }
733
734        for (migration, dep) in load_all_edges(store) {
735            if dep != q.name {
736                continue;
737            }
738            let Some((_, dependent_fields)) = find_migration(store, &migration) else {
739                continue;
740            };
741            if dependent_fields.get("status").and_then(|v| val_text(v)) == Some("applied") {
742                return Err(RedDBError::Query(format!(
743                    "cannot rollback '{}' - applied migration '{}' depends on it",
744                    q.name, migration
745                )));
746            }
747        }
748
749        let commit_hash = fields
750            .get("vcs_commit_hash")
751            .and_then(|v| val_text(v))
752            .unwrap_or("")
753            .to_string();
754
755        if commit_hash.is_empty() {
756            return Err(RedDBError::Query(format!(
757                "migration '{}' has no VCS commit hash to revert",
758                q.name
759            )));
760        }
761
762        let author = migration_author(self);
763        self.vcs_revert(0, &commit_hash, author).map_err(|e| {
764            RedDBError::Query(format!(
765                "rollback of migration '{}' failed: VCS revert failed: {e}",
766                q.name
767            ))
768        })?;
769
770        let _ = update_migration_field(store, &q.name, "status", Value::text("pending"));
771        let _ = update_migration_field(store, &q.name, "applied_at", Value::Null);
772        let _ = update_migration_field(store, &q.name, "vcs_commit_hash", Value::Null);
773
774        Ok(RuntimeQueryResult::ok_message(
775            raw_query.to_string(),
776            &format!("migration '{}' rolled back (status: pending)", q.name),
777            "rollback_migration",
778        ))
779    }
780
781    /// EXPLAIN MIGRATION name
782    pub fn execute_explain_migration(
783        &self,
784        raw_query: &str,
785        q: &ExplainMigrationQuery,
786    ) -> RedDBResult<RuntimeQueryResult> {
787        let store_arc = self.inner.db.store();
788        let store: &UnifiedStore = &store_arc;
789
790        let columns = vec![
791            "migration".to_string(),
792            "status".to_string(),
793            "kind".to_string(),
794            "body".to_string(),
795            "estimated_rows".to_string(),
796            "lock_duration_ms".to_string(),
797        ];
798
799        if q.name == "*" {
800            let mut rows = Vec::new();
801            for name in self.collect_pending_migrations(store) {
802                let Some((_, fields)) = find_migration(store, &name) else {
803                    continue;
804                };
805                rows.push(explain_migration_row(&name, &fields));
806            }
807            return Ok(RuntimeQueryResult::ok_records(
808                raw_query.to_string(),
809                columns,
810                rows,
811                "explain_migration",
812            ));
813        }
814
815        let (_, fields) = find_migration(store, &q.name)
816            .ok_or_else(|| RedDBError::NotFound(format!("migration '{}' not found", q.name)))?;
817
818        Ok(RuntimeQueryResult::ok_records(
819            raw_query.to_string(),
820            columns,
821            vec![explain_migration_row(&q.name, &fields)],
822            "explain_migration",
823        ))
824    }
825}
826
827fn explain_migration_row(name: &str, fields: &HashMap<String, Value>) -> Vec<(String, Value)> {
828    let status = fields
829        .get("status")
830        .and_then(|v| val_text(v))
831        .unwrap_or("unknown");
832    let body = fields.get("body").and_then(|v| val_text(v)).unwrap_or("");
833    let kind = fields
834        .get("kind")
835        .and_then(|v| val_text(v))
836        .unwrap_or("schema");
837
838    vec![
839        ("migration".to_string(), Value::text(name)),
840        ("status".to_string(), Value::text(status)),
841        ("kind".to_string(), Value::text(kind)),
842        ("body".to_string(), Value::text(body)),
843        ("estimated_rows".to_string(), Value::Null),
844        ("lock_duration_ms".to_string(), Value::UnsignedInteger(0)),
845    ]
846}