systemprompt_database/lifecycle/migrations/
mod.rs1mod down;
11mod exec;
12mod mark_applied;
13mod repair;
14mod stamp;
15mod status;
16mod verify;
17
18pub use mark_applied::MarkAppliedOutcome;
19pub use repair::RepairResult;
20pub use stamp::{BaselineStamp, FreshnessCheck};
21pub use status::{
22 AppliedMigration, ChecksumDrift, ExtensionMigrationStatus, MigrationResult, MigrationStatus,
23 OrphanedMigration, PendingMigration, SlotCollision, TombstonedSlot,
24};
25
26use crate::services::{DatabaseProvider, SqlExecutor};
27use exec::{TrackingWrite, check_cross_extension_alters, execute_statements_transactional};
28use std::collections::HashSet;
29use systemprompt_extension::{Extension, LoaderError, Migration};
30use systemprompt_identifiers::ToDbValue;
31use tracing::{debug, info, warn};
32
33pub(crate) const RECORD_MIGRATION_SQL: &str = "INSERT INTO extension_migrations (id, extension_id, version, \
34 name, checksum) VALUES ($1, $2, $3, $4, $5)";
35
36#[derive(Debug, Default, Clone, Copy)]
37pub struct MigrationConfig {
38 pub allow_checksum_drift: bool,
39}
40
41pub struct MigrationService<'a> {
42 db: &'a dyn DatabaseProvider,
43 config: MigrationConfig,
44}
45
46impl std::fmt::Debug for MigrationService<'_> {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 f.debug_struct("MigrationService")
49 .field("config", &self.config)
50 .finish_non_exhaustive()
51 }
52}
53
54impl<'a> MigrationService<'a> {
55 pub fn new(db: &'a dyn DatabaseProvider) -> Self {
56 Self {
57 db,
58 config: MigrationConfig::default(),
59 }
60 }
61
62 #[must_use]
63 pub const fn with_config(mut self, config: MigrationConfig) -> Self {
64 self.config = config;
65 self
66 }
67
68 async fn ensure_migrations_table_exists(&self) -> Result<(), LoaderError> {
69 let sql = include_str!("../../../schema/extension_migrations.sql");
70 SqlExecutor::execute_statements_parsed(self.db, sql)
71 .await
72 .map_err(|e| LoaderError::MigrationFailed {
73 extension: "database".to_owned(),
74 message: format!("Failed to ensure migrations table exists: {e}"),
75 })
76 }
77
78 pub async fn get_applied_migrations(
79 &self,
80 extension_id: &str,
81 ) -> Result<Vec<AppliedMigration>, LoaderError> {
82 let result = self
83 .db
84 .query_raw_with(
85 &"SELECT extension_id, version, name, checksum, applied_at FROM \
86 extension_migrations WHERE extension_id = $1 ORDER BY version",
87 &[&extension_id],
88 )
89 .await
90 .map_err(|e| LoaderError::MigrationFailed {
91 extension: extension_id.to_owned(),
92 message: format!("Failed to query applied migrations: {e}"),
93 })?;
94
95 let migrations = result
96 .rows
97 .iter()
98 .filter_map(|row| {
99 Some(AppliedMigration {
100 extension_id: row.get("extension_id")?.as_str()?.to_owned(),
101 version: row.get("version")?.as_i64()? as u32,
102 name: row.get("name")?.as_str()?.to_owned(),
103 checksum: row.get("checksum")?.as_str()?.to_owned(),
104 applied_at: row
105 .get("applied_at")
106 .and_then(|v| v.as_str().map(String::from)),
107 })
108 })
109 .collect();
110
111 Ok(migrations)
112 }
113
114 pub async fn run_pending_migrations(
115 &self,
116 extension: &dyn Extension,
117 ) -> Result<MigrationResult, LoaderError> {
118 let ext_id = extension.metadata().id;
119 let migrations = extension.migrations();
120
121 if migrations.is_empty() {
122 return Ok(MigrationResult::default());
123 }
124
125 self.ensure_migrations_table_exists().await?;
126
127 let applied = self.get_applied_migrations(ext_id).await?;
128 let applied_rows: std::collections::HashMap<u32, &AppliedMigration> =
129 applied.iter().map(|m| (m.version, m)).collect();
130
131 warn_orphaned_versions(ext_id, &applied, &migrations);
132
133 let mut migrations_run = 0;
134 let mut migrations_skipped = 0;
135
136 for migration in &migrations {
137 let row = applied_rows.get(&migration.version).copied();
138
139 if migration.tombstone {
140 debug!(
147 extension = %ext_id,
148 version = migration.version,
149 name = %migration.name,
150 tracked = row.is_some(),
151 "Migration slot is tombstoned, nothing to run"
152 );
153 continue;
154 }
155
156 if let Some(row) = row {
157 self.verify_slot_identity(ext_id, migration, Some(row))?;
158 self.verify_checksum(ext_id, migration, Some(row.checksum.as_str()))?;
159 migrations_skipped += 1;
160 debug!(
161 extension = %ext_id,
162 version = migration.version,
163 "Migration already applied, skipping"
164 );
165 continue;
166 }
167
168 self.execute_migration(extension, migration).await?;
169 migrations_run += 1;
170 }
171
172 if migrations_run > 0 {
173 info!(
174 extension = %ext_id,
175 migrations_run,
176 migrations_skipped,
177 "Migrations completed"
178 );
179 }
180
181 Ok(MigrationResult {
182 migrations_run,
183 migrations_skipped,
184 })
185 }
186
187 async fn execute_migration(
191 &self,
192 extension: &dyn Extension,
193 migration: &Migration,
194 ) -> Result<(), LoaderError> {
195 let ext_id = extension.metadata().id;
196
197 check_cross_extension_alters(extension, migration)?;
198
199 info!(
200 extension = %ext_id,
201 version = migration.version,
202 name = %migration.name,
203 no_transaction = migration.no_transaction,
204 "Running migration"
205 );
206
207 let id = format!("{}_{:03}", ext_id, migration.version);
208 let checksum = migration.checksum();
209 let record_params: [&dyn ToDbValue; 5] =
210 [&id, &ext_id, &migration.version, &migration.name, &checksum];
211
212 if migration.no_transaction {
213 SqlExecutor::execute_statements_parsed(self.db, migration.sql)
214 .await
215 .map_err(|e| LoaderError::MigrationFailed {
216 extension: ext_id.to_owned(),
217 message: format!(
218 "Failed to execute migration {} ({}): {e}",
219 migration.version, migration.name
220 ),
221 })?;
222 self.db
223 .execute(&RECORD_MIGRATION_SQL, &record_params)
224 .await
225 .map_err(|e| LoaderError::MigrationFailed {
226 extension: ext_id.to_owned(),
227 message: format!("Failed to record migration: {e}"),
228 })?;
229 } else {
230 let statements = SqlExecutor::parse_sql_statements(migration.sql).map_err(|e| {
231 LoaderError::MigrationFailed {
232 extension: ext_id.to_owned(),
233 message: format!(
234 "Failed to parse migration {} ({}): {e}",
235 migration.version, migration.name
236 ),
237 }
238 })?;
239 execute_statements_transactional(
240 self.db,
241 &statements,
242 ext_id,
243 migration,
244 Some(TrackingWrite {
245 sql: RECORD_MIGRATION_SQL,
246 params: &record_params,
247 }),
248 )
249 .await?;
250 }
251
252 Ok(())
253 }
254}
255
256pub(crate) fn orphaned_versions(applied: &[AppliedMigration], defined: &[Migration]) -> Vec<u32> {
261 let declared: HashSet<u32> = defined.iter().map(|m| m.version).collect();
262 applied
263 .iter()
264 .map(|m| m.version)
265 .filter(|version| !declared.contains(version))
266 .collect()
267}
268
269fn warn_orphaned_versions(ext_id: &str, applied: &[AppliedMigration], defined: &[Migration]) {
270 let orphaned = orphaned_versions(applied, defined);
271 if orphaned.is_empty() {
272 return;
273 }
274 warn!(
275 extension = %ext_id,
276 versions = ?orphaned,
277 "Applied migrations are no longer declared by the extension; their files were deleted \
278 without leaving a tombstone, so the numbers look free but are spent"
279 );
280}