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