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