systemprompt_database/lifecycle/migrations/
squash.rs1use super::MigrationService;
8use std::collections::HashSet;
9use systemprompt_extension::{Extension, LoaderError, Migration};
10use tracing::info;
11
12#[derive(Debug, Clone)]
13pub struct SquashPlan {
14 pub extension_id: String,
15 pub through: u32,
16 pub baseline_name: String,
17 pub baseline_sql: String,
18 pub baseline_checksum: String,
19 pub source_versions: Vec<u32>,
20 pub already_applied_versions: Vec<u32>,
21 pub applied: bool,
22}
23
24fn baseline_checksum(sql: &str) -> String {
25 use std::hash::{Hash, Hasher};
26 let mut hasher = std::collections::hash_map::DefaultHasher::new();
27 sql.hash(&mut hasher);
28 format!("{:x}", hasher.finish())
29}
30
31fn collect_squash_range<'m>(
32 ext_id: &str,
33 migrations: &'m [Migration],
34 through: u32,
35) -> Result<Vec<&'m Migration>, LoaderError> {
36 if through == 0 {
37 return Err(LoaderError::MigrationFailed {
38 extension: ext_id.to_owned(),
39 message: "--through must be >= 1; version 0 is reserved for the squash baseline"
40 .to_owned(),
41 });
42 }
43
44 let to_squash: Vec<&Migration> = migrations
45 .iter()
46 .filter(|m| m.version >= 1 && m.version <= through)
47 .collect();
48
49 if to_squash.is_empty() {
50 return Err(LoaderError::MigrationFailed {
51 extension: ext_id.to_owned(),
52 message: format!(
53 "No migrations in range 1..={through} are defined for extension '{ext_id}'"
54 ),
55 });
56 }
57
58 let mut covered: Vec<u32> = to_squash.iter().map(|m| m.version).collect();
59 covered.sort_unstable();
60 if covered != (1..=through).collect::<Vec<u32>>() {
61 return Err(LoaderError::MigrationFailed {
62 extension: ext_id.to_owned(),
63 message: format!(
64 "Migrations 1..={through} are not contiguous for extension '{ext_id}': have \
65 {covered:?}"
66 ),
67 });
68 }
69
70 Ok(to_squash)
71}
72
73fn build_baseline_sql(to_squash: &[&Migration]) -> String {
74 let mut baseline_sql = String::new();
75 for m in to_squash {
76 baseline_sql.push_str(&format!(
77 "-- migration {ver:03}: {name}\n",
78 ver = m.version,
79 name = m.name
80 ));
81 baseline_sql.push_str(m.sql);
82 if !m.sql.ends_with('\n') {
83 baseline_sql.push('\n');
84 }
85 baseline_sql.push('\n');
86 }
87 baseline_sql
88}
89
90impl MigrationService<'_> {
91 pub async fn squash_through(
92 &self,
93 extension: &dyn Extension,
94 through: u32,
95 apply: bool,
96 ) -> Result<SquashPlan, LoaderError> {
97 let ext_id = extension.metadata().id;
98
99 let mut migrations = extension.migrations();
100 migrations.sort_by_key(|m| m.version);
101 let to_squash = collect_squash_range(ext_id, &migrations, through)?;
102
103 let baseline_sql = build_baseline_sql(&to_squash);
104 let checksum = baseline_checksum(&baseline_sql);
105 let baseline_name = format!("baseline_v{through}");
106 let covered: Vec<u32> = to_squash.iter().map(|m| m.version).collect();
107
108 self.verify_range_applied(ext_id, through).await?;
109
110 let plan = SquashPlan {
111 extension_id: ext_id.to_owned(),
112 through,
113 baseline_name: baseline_name.clone(),
114 baseline_sql,
115 baseline_checksum: checksum.clone(),
116 source_versions: covered,
117 already_applied_versions: (1..=through).collect(),
118 applied: false,
119 };
120
121 if !apply {
122 return Ok(plan);
123 }
124
125 self.apply_squash_rows(ext_id, through, &baseline_name, &checksum)
126 .await?;
127
128 Ok(SquashPlan {
129 applied: true,
130 ..plan
131 })
132 }
133
134 async fn verify_range_applied(&self, ext_id: &str, through: u32) -> Result<(), LoaderError> {
135 self.ensure_migrations_table_exists().await?;
136 let applied = self.get_applied_migrations(ext_id).await?;
137 let applied_versions: HashSet<u32> = applied.iter().map(|m| m.version).collect();
138 let not_applied: Vec<u32> = (1..=through)
139 .filter(|v| !applied_versions.contains(v))
140 .collect();
141 if not_applied.is_empty() {
142 return Ok(());
143 }
144 Err(LoaderError::MigrationFailed {
145 extension: ext_id.to_owned(),
146 message: format!(
147 "Refusing to squash through {through}: extension '{ext_id}' has not applied \
148 migrations {not_applied:?}. Squashing would retire them behind the baseline \
149 without ever running them. Apply migrations 1..={through} first."
150 ),
151 })
152 }
153
154 async fn apply_squash_rows(
155 &self,
156 ext_id: &str,
157 through: u32,
158 baseline_name: &str,
159 checksum: &str,
160 ) -> Result<(), LoaderError> {
161 let baseline_id = format!("{ext_id}_000");
162
163 self.db
164 .execute(
165 &"INSERT INTO extension_migrations (id, extension_id, version, name, checksum) \
166 VALUES ($1, $2, 0, $3, $4) ON CONFLICT (extension_id, version) DO UPDATE SET \
167 name = EXCLUDED.name, checksum = EXCLUDED.checksum",
168 &[&baseline_id, &ext_id, &baseline_name, &checksum],
169 )
170 .await
171 .map_err(|e| LoaderError::MigrationFailed {
172 extension: ext_id.to_owned(),
173 message: format!("Failed to record baseline migration row: {e}"),
174 })?;
175
176 self.db
177 .execute(
178 &"DELETE FROM extension_migrations WHERE extension_id = $1 AND version BETWEEN 1 \
179 AND $2",
180 &[&ext_id, &through],
181 )
182 .await
183 .map_err(|e| LoaderError::MigrationFailed {
184 extension: ext_id.to_owned(),
185 message: format!("Failed to retire squashed migration rows: {e}"),
186 })?;
187
188 info!(
189 extension = %ext_id,
190 through,
191 baseline_name = %baseline_name,
192 "Squash applied: baseline row inserted, source rows retired"
193 );
194
195 Ok(())
196 }
197}