Skip to main content

systemprompt_database/lifecycle/migrations/
status.rs

1//! Migration status and plan queries, plus the value types they return.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use super::MigrationService;
7use std::collections::HashSet;
8use systemprompt_extension::{Extension, LoaderError, Migration};
9
10#[derive(Debug, Clone)]
11pub struct AppliedMigration {
12    pub extension_id: String,
13    pub version: u32,
14    pub name: String,
15    pub checksum: String,
16    pub applied_at: Option<String>,
17}
18
19#[derive(Debug, Clone)]
20pub struct PendingMigration {
21    pub extension_id: String,
22    pub version: u32,
23    pub name: String,
24    pub sql: &'static str,
25    pub checksum: String,
26    pub no_tx: bool,
27}
28
29/// An applied migration whose slot the extension no longer declares at all.
30///
31/// The file was deleted without leaving a `.tombstone`, so the number reads as
32/// free in the tree while every established database has spent it.
33#[derive(Debug, Clone)]
34pub struct OrphanedMigration {
35    pub extension_id: String,
36    pub version: u32,
37    pub name: String,
38}
39
40/// A slot declared spent by a `.tombstone` file.
41#[derive(Debug, Clone)]
42pub struct TombstonedSlot {
43    pub extension_id: String,
44    pub version: u32,
45    pub name: String,
46    pub tracked: bool,
47}
48
49/// An applied migration whose slot is now occupied by a differently-named file.
50///
51/// This is not drift: drift means the same migration was edited in place. A
52/// name mismatch means the number was reused by a different migration, so the
53/// recorded row and the file on disk describe two different things and neither
54/// checksum tells the truth about the database.
55#[derive(Debug, Clone)]
56pub struct SlotCollision {
57    pub extension_id: String,
58    pub version: u32,
59    pub stored_name: String,
60    pub current_name: String,
61}
62
63#[derive(Debug, Clone)]
64pub struct ChecksumDrift {
65    pub extension_id: String,
66    pub version: u32,
67    pub name: String,
68    pub stored_checksum: String,
69    pub current_checksum: String,
70}
71
72#[derive(Debug, Clone, Default)]
73pub struct ExtensionMigrationStatus {
74    pub extension_id: String,
75    pub applied: Vec<AppliedMigration>,
76    pub pending: Vec<PendingMigration>,
77    pub drift: Vec<ChecksumDrift>,
78    pub slot_collisions: Vec<SlotCollision>,
79    pub orphaned: Vec<OrphanedMigration>,
80    pub tombstoned: Vec<TombstonedSlot>,
81}
82
83#[derive(Debug, Default, Clone, Copy)]
84pub struct MigrationResult {
85    pub migrations_run: usize,
86    pub migrations_skipped: usize,
87}
88
89#[derive(Debug)]
90pub struct MigrationStatus {
91    pub extension_id: String,
92    pub total_defined: usize,
93    pub total_applied: usize,
94    pub pending_count: usize,
95    pub pending: Vec<Migration>,
96    pub applied: Vec<AppliedMigration>,
97}
98
99impl MigrationService<'_> {
100    pub async fn plan_pending(
101        &self,
102        extension: &dyn Extension,
103    ) -> Result<Vec<PendingMigration>, LoaderError> {
104        let ext_id = extension.metadata().id;
105        let defined = extension.migrations();
106
107        if defined.is_empty() {
108            return Ok(Vec::new());
109        }
110
111        self.ensure_migrations_table_exists().await?;
112        let applied_versions: HashSet<u32> = self
113            .get_applied_migrations(ext_id)
114            .await?
115            .into_iter()
116            .map(|m| m.version)
117            .collect();
118
119        Ok(defined
120            .into_iter()
121            .filter(|m| !m.tombstone && !applied_versions.contains(&m.version))
122            .map(|m| PendingMigration {
123                extension_id: ext_id.to_owned(),
124                version: m.version,
125                name: m.name.clone(),
126                sql: m.sql,
127                checksum: m.checksum(),
128                no_tx: m.no_transaction,
129            })
130            .collect())
131    }
132
133    pub async fn status(
134        &self,
135        extension: &dyn Extension,
136    ) -> Result<ExtensionMigrationStatus, LoaderError> {
137        let ext_id = extension.metadata().id;
138        let defined = extension.migrations();
139
140        self.ensure_migrations_table_exists().await?;
141        let applied = self.get_applied_migrations(ext_id).await?;
142
143        let applied_versions: HashSet<u32> = applied.iter().map(|m| m.version).collect();
144        let applied_rows: std::collections::HashMap<u32, &AppliedMigration> =
145            applied.iter().map(|m| (m.version, m)).collect();
146
147        let mut pending = Vec::new();
148        let mut drift = Vec::new();
149        let mut slot_collisions = Vec::new();
150        let mut tombstoned = Vec::new();
151
152        for m in &defined {
153            if m.tombstone {
154                tombstoned.push(TombstonedSlot {
155                    extension_id: ext_id.to_owned(),
156                    version: m.version,
157                    name: m.name.clone(),
158                    tracked: applied_versions.contains(&m.version),
159                });
160                continue;
161            }
162            let current_checksum = m.checksum();
163            if let Some(row) = applied_rows.get(&m.version).copied() {
164                if row.name != m.name {
165                    slot_collisions.push(SlotCollision {
166                        extension_id: ext_id.to_owned(),
167                        version: m.version,
168                        stored_name: row.name.clone(),
169                        current_name: m.name.clone(),
170                    });
171                } else if row.checksum != current_checksum {
172                    drift.push(ChecksumDrift {
173                        extension_id: ext_id.to_owned(),
174                        version: m.version,
175                        name: m.name.clone(),
176                        stored_checksum: row.checksum.clone(),
177                        current_checksum,
178                    });
179                }
180            } else {
181                pending.push(PendingMigration {
182                    extension_id: ext_id.to_owned(),
183                    version: m.version,
184                    name: m.name.clone(),
185                    sql: m.sql,
186                    checksum: current_checksum,
187                    no_tx: m.no_transaction,
188                });
189            }
190        }
191
192        let orphaned = super::orphaned_versions(&applied, &defined)
193            .into_iter()
194            .map(|version| OrphanedMigration {
195                extension_id: ext_id.to_owned(),
196                version,
197                name: applied
198                    .iter()
199                    .find(|m| m.version == version)
200                    .map_or_else(String::new, |m| m.name.clone()),
201            })
202            .collect();
203
204        Ok(ExtensionMigrationStatus {
205            extension_id: ext_id.to_owned(),
206            applied,
207            pending,
208            drift,
209            slot_collisions,
210            orphaned,
211            tombstoned,
212        })
213    }
214
215    pub async fn get_migration_status(
216        &self,
217        extension: &dyn Extension,
218    ) -> Result<MigrationStatus, LoaderError> {
219        self.ensure_migrations_table_exists().await?;
220
221        let ext_id = extension.metadata().id;
222        let defined_migrations = extension.migrations();
223        let applied = self.get_applied_migrations(ext_id).await?;
224
225        let applied_versions: HashSet<u32> = applied.iter().map(|m| m.version).collect();
226
227        let pending: Vec<_> = defined_migrations
228            .iter()
229            .filter(|m| !m.tombstone && !applied_versions.contains(&m.version))
230            .cloned()
231            .collect();
232
233        Ok(MigrationStatus {
234            extension_id: ext_id.to_owned(),
235            total_defined: defined_migrations.len(),
236            total_applied: applied.len(),
237            pending_count: pending.len(),
238            pending,
239            applied,
240        })
241    }
242}