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