systemprompt_database/lifecycle/migrations/
repair.rs1use super::exec::{TrackingWrite, check_cross_extension_alters, execute_statements_transactional};
25use super::{ChecksumDrift, ExtensionMigrationStatus, MigrationService};
26use crate::lifecycle::installation::BootstrapLockGuard;
27use crate::services::SqlExecutor;
28use systemprompt_extension::{Extension, LoaderError, Migration};
29use systemprompt_identifiers::{ExtensionId, ToDbValue};
30
31const UPDATE_CHECKSUM_SQL: &str =
32 "UPDATE extension_migrations SET checksum = $3 WHERE extension_id = $1 AND version = $2";
33
34#[derive(Debug, Default, Clone)]
35pub struct RepairResult {
36 pub repaired: Vec<ChecksumDrift>,
37 pub reapplied: usize,
38 pub migrations_run: usize,
39}
40
41impl MigrationService<'_> {
42 pub async fn repair_drift(
43 &self,
44 extension: &dyn Extension,
45 ) -> Result<RepairResult, LoaderError> {
46 let status = self.status(extension).await?;
47 Self::refuse_slot_collisions(&status)?;
48
49 if status.drift.is_empty() {
50 return Ok(RepairResult::default());
51 }
52
53 let reapplied = status.drift.len();
54 let guard = BootstrapLockGuard::acquire(self.db).await?;
55 let outcome = self.reapply_drifted(extension, &status.drift).await;
56 let pending = match outcome {
57 Ok(()) => self.run_pending_migrations(extension).await,
58 Err(e) => Err(e),
59 };
60 guard.release().await;
61 let result = pending?;
62
63 Ok(RepairResult {
64 repaired: status.drift,
65 reapplied,
66 migrations_run: result.migrations_run,
67 })
68 }
69
70 pub async fn reconcile_drift(
71 &self,
72 extension: &dyn Extension,
73 ) -> Result<RepairResult, LoaderError> {
74 let status = self.status(extension).await?;
75 Self::refuse_slot_collisions(&status)?;
76
77 if status.drift.is_empty() {
78 return Ok(RepairResult::default());
79 }
80
81 let guard = BootstrapLockGuard::acquire(self.db).await?;
82 let mut outcome = Ok(());
83 for drift in &status.drift {
84 if let Err(e) = self.rewrite_checksum(drift).await {
85 outcome = Err(e);
86 break;
87 }
88 }
89 guard.release().await;
90 outcome?;
91
92 Ok(RepairResult {
93 repaired: status.drift,
94 reapplied: 0,
95 migrations_run: 0,
96 })
97 }
98
99 pub fn refuse_slot_collisions(status: &ExtensionMigrationStatus) -> Result<(), LoaderError> {
100 let Some(collision) = status.slot_collisions.first() else {
101 return Ok(());
102 };
103 Err(LoaderError::MigrationSlotReused {
104 extension: collision.extension_id.clone(),
105 version: collision.version,
106 stored_name: collision.stored_name.clone(),
107 current_name: collision.current_name.clone(),
108 })
109 }
110
111 async fn rewrite_checksum(&self, drift: &ChecksumDrift) -> Result<(), LoaderError> {
112 self.db
113 .execute(
114 &UPDATE_CHECKSUM_SQL,
115 &[&drift.extension_id, &drift.version, &drift.current_checksum],
116 )
117 .await
118 .map_err(|e| LoaderError::MigrationStepFailed {
119 extension: drift.extension_id.clone(),
120 context: format!(
121 "Failed to rewrite checksum for migration {} ('{}')",
122 drift.version, drift.name
123 ),
124 source: Box::new(e),
125 })?;
126 Ok(())
127 }
128
129 async fn reapply_drifted(
130 &self,
131 extension: &dyn Extension,
132 drift: &[ChecksumDrift],
133 ) -> Result<(), LoaderError> {
134 let ext_id = &ExtensionId::new(extension.metadata().id);
135 let migrations = extension.migrations();
136
137 for d in drift {
138 let migration = migrations
139 .iter()
140 .find(|m| m.version == d.version)
141 .ok_or_else(|| LoaderError::MigrationFailed {
142 extension: ext_id.clone(),
143 message: format!(
144 "Drifted migration {} ('{}') is no longer declared by extension \
145 '{ext_id}'",
146 d.version, d.name
147 ),
148 })?;
149 self.reapply_one(extension, migration, d).await?;
150 }
151
152 Ok(())
153 }
154
155 async fn reapply_one(
156 &self,
157 extension: &dyn Extension,
158 migration: &Migration,
159 drift: &ChecksumDrift,
160 ) -> Result<(), LoaderError> {
161 let ext_id = &ExtensionId::new(extension.metadata().id);
162
163 check_cross_extension_alters(extension, migration)?;
164
165 tracing::info!(
166 extension = %ext_id,
167 version = migration.version,
168 name = %migration.name,
169 no_transaction = migration.no_transaction,
170 "Re-applying drifted migration"
171 );
172
173 let update_params: [&dyn ToDbValue; 3] =
174 [&drift.extension_id, &drift.version, &drift.current_checksum];
175
176 if migration.no_transaction {
177 SqlExecutor::execute_statements_parsed(self.db, migration.sql)
178 .await
179 .map_err(|e| LoaderError::MigrationStepFailed {
180 extension: ext_id.clone(),
181 context: format!(
182 "Failed to re-apply drifted migration {} ({})",
183 migration.version, migration.name
184 ),
185 source: Box::new(e),
186 })?;
187 self.rewrite_checksum(drift).await
188 } else {
189 let statements = SqlExecutor::parse_sql_statements(migration.sql).map_err(|e| {
190 LoaderError::MigrationStepFailed {
191 extension: ext_id.clone(),
192 context: format!(
193 "Failed to parse migration {} ({})",
194 migration.version, migration.name
195 ),
196 source: Box::new(e),
197 }
198 })?;
199 execute_statements_transactional(
200 self.db,
201 &statements,
202 ext_id,
203 migration,
204 Some(TrackingWrite {
205 sql: UPDATE_CHECKSUM_SQL,
206 params: &update_params,
207 }),
208 )
209 .await
210 }
211 }
212}