1use crate::error::AwaError;
2use sqlx::postgres::PgConnection;
3use sqlx::PgPool;
4use tracing::info;
5
6pub const CURRENT_VERSION: i32 = 10;
8
9const MIGRATIONS: &[(i32, &str, &[&str])] = &[
22 (1, "Canonical schema with UI indexes", &[V1_UP]),
23 (2, "Runtime observability snapshots", &[V2_UP]),
24 (3, "Maintenance loop health in runtime snapshots", &[V3_UP]),
25 (4, "Admin metadata cache tables", &[V4_UP]),
26 (5, "Statement-level admin metadata triggers", &[V5_UP]),
27 (
28 6,
29 "Dirty-key statement triggers for deadlock-free admin metadata",
30 &[V6_UP],
31 ),
32 (
33 7,
34 "Backoff interval creation avoids scientific-notation parse failures",
35 &[V7_UP],
36 ),
37 (9, "Queue and job-kind descriptor catalogs", &[V9_UP]),
40 (
41 10,
42 "Storage transition metadata and canonical compat routing",
43 &[V10_UP],
44 ),
45];
46
47const V1_UP: &str = include_str!("../migrations/v001_canonical_schema.sql");
48const V2_UP: &str = include_str!("../migrations/v002_runtime_instances.sql");
49const V3_UP: &str = include_str!("../migrations/v003_maintenance_health.sql");
50const V4_UP: &str = include_str!("../migrations/v004_admin_metadata.sql");
51const V5_UP: &str = include_str!("../migrations/v005_admin_metadata_stmt_triggers.sql");
52const V6_UP: &str = include_str!("../migrations/v006_remove_hot_table_triggers.sql");
53const V7_UP: &str = include_str!("../migrations/v007_backoff_interval_fix.sql");
54const V9_UP: &str = include_str!("../migrations/v009_descriptors.sql");
55const V10_UP: &str = include_str!("../migrations/v010_storage_transition_prep.sql");
56
57fn normalize_legacy_version(old_version: i32) -> i32 {
61 match old_version {
62 v if v >= 6 => 4, 5 => 3, 4 => 2, 3 => 1, _ => 0, }
68}
69
70pub async fn run(pool: &PgPool) -> Result<(), AwaError> {
80 let lock_key: i64 = 0x4157_415f_4d49_4752; let mut conn = pool.acquire().await?;
82 sqlx::query("SELECT pg_advisory_lock($1)")
83 .bind(lock_key)
84 .execute(&mut *conn)
85 .await?;
86
87 let result = run_inner(&mut conn).await;
88
89 let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
90 .bind(lock_key)
91 .execute(&mut *conn)
92 .await;
93
94 result
95}
96
97async fn run_inner(conn: &mut PgConnection) -> Result<(), AwaError> {
98 let has_schema: bool =
99 sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM pg_namespace WHERE nspname = 'awa')")
100 .fetch_one(&mut *conn)
101 .await?;
102
103 let current = if has_schema {
104 current_version_conn(conn).await?
105 } else {
106 0
107 };
108
109 if !(has_schema && current == CURRENT_VERSION) {
110 for &(version, description, steps) in MIGRATIONS {
111 if version <= current {
112 continue;
113 }
114 info!(version, description, "Applying migration");
115 for step in steps {
116 sqlx::raw_sql(step).execute(&mut *conn).await?;
117 }
118 info!(version, "Migration applied");
119 }
120 } else {
121 info!(version = current, "Schema is up to date");
122 }
123
124 let has_refresh: bool = sqlx::query_scalar(
129 "SELECT EXISTS(SELECT 1 FROM pg_proc WHERE proname = 'refresh_admin_metadata' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'awa'))",
130 )
131 .fetch_one(&mut *conn)
132 .await?;
133 if has_refresh {
134 let _ = sqlx::raw_sql(
140 "BEGIN; SET LOCAL statement_timeout = '5s'; SELECT awa.refresh_admin_metadata(); COMMIT;",
141 )
142 .execute(&mut *conn)
143 .await;
144 }
145
146 Ok(())
147}
148
149pub async fn current_version(pool: &PgPool) -> Result<i32, AwaError> {
151 let mut conn = pool.acquire().await?;
152 current_version_conn(&mut conn).await
153}
154
155async fn current_version_conn(conn: &mut PgConnection) -> Result<i32, AwaError> {
156 let has_schema: bool =
157 sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM pg_namespace WHERE nspname = 'awa')")
158 .fetch_one(&mut *conn)
159 .await?;
160
161 if !has_schema {
162 return Ok(0);
163 }
164
165 let has_table: bool = sqlx::query_scalar(
166 "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'awa' AND table_name = 'schema_version')",
167 )
168 .fetch_one(&mut *conn)
169 .await?;
170
171 if !has_table {
172 return Ok(0);
173 }
174
175 let version: Option<i32> = sqlx::query_scalar("SELECT MAX(version) FROM awa.schema_version")
176 .fetch_one(&mut *conn)
177 .await?;
178
179 let raw_version = version.unwrap_or(0);
180
181 if (1..=CURRENT_VERSION).contains(&raw_version) {
184 let has_admin_tables: bool = sqlx::query_scalar(
187 "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'awa' AND table_name = 'queue_state_counts')",
188 )
189 .fetch_one(&mut *conn)
190 .await
191 .unwrap_or(false);
192
193 if raw_version >= 4 && has_admin_tables {
196 return Ok(raw_version);
197 }
198 if raw_version <= 3 {
200 let has_runtime: bool = sqlx::query_scalar(
201 "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'awa' AND table_name = 'runtime_instances')",
202 )
203 .fetch_one(&mut *conn)
204 .await
205 .unwrap_or(false);
206 if (raw_version >= 2 && has_runtime) || raw_version == 1 {
208 return Ok(raw_version);
209 }
210 }
211 }
212
213 let has_legacy_high: bool =
217 sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM awa.schema_version WHERE version >= 6)")
218 .fetch_one(&mut *conn)
219 .await
220 .unwrap_or(false);
221
222 let has_admin_metadata: bool = sqlx::query_scalar(
223 "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'awa' AND table_name = 'queue_state_counts')",
224 )
225 .fetch_one(&mut *conn)
226 .await
227 .unwrap_or(false);
228
229 let is_legacy_v5_only = raw_version == 5 && !has_legacy_high && !has_admin_metadata;
230 let is_legacy_v4_only = raw_version == 4 && !has_legacy_high && !has_admin_metadata;
231
232 let is_legacy_v3_only = raw_version == 3
235 && !has_legacy_high
236 && {
237 let has_runtime: bool = sqlx::query_scalar(
238 "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'awa' AND table_name = 'runtime_instances')",
239 )
240 .fetch_one(&mut *conn)
241 .await
242 .unwrap_or(false);
243 !has_runtime
244 };
245
246 if has_legacy_high || is_legacy_v5_only || is_legacy_v4_only || is_legacy_v3_only {
247 let normalized = normalize_legacy_version(raw_version);
248 info!(
249 old_version = raw_version,
250 new_version = normalized,
251 "Normalizing legacy version numbering"
252 );
253 sqlx::query("DELETE FROM awa.schema_version WHERE version >= 3")
255 .execute(&mut *conn)
256 .await?;
257 for &(v, desc, _) in MIGRATIONS {
258 if v <= normalized {
259 sqlx::query(
260 "INSERT INTO awa.schema_version (version, description) VALUES ($1, $2) ON CONFLICT (version) DO NOTHING",
261 )
262 .bind(v)
263 .bind(desc)
264 .execute(&mut *conn)
265 .await?;
266 }
267 }
268 return Ok(normalized);
269 }
270
271 Ok(raw_version)
272}
273
274pub fn migration_sql() -> Vec<(i32, &'static str, String)> {
276 MIGRATIONS
277 .iter()
278 .map(|&(v, d, steps)| (v, d, steps.join("\n")))
279 .collect()
280}
281
282pub fn migration_sql_range(from: i32, to: i32) -> Vec<(i32, &'static str, String)> {
285 MIGRATIONS
286 .iter()
287 .filter(|&&(v, _, _)| v > from && v <= to)
288 .map(|&(v, d, steps)| (v, d, steps.join("\n")))
289 .collect()
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295
296 #[test]
297 fn migration_sql_range_all() {
298 let all = migration_sql_range(0, CURRENT_VERSION);
299 assert_eq!(all.len(), MIGRATIONS.len());
300 assert_eq!(all.first().unwrap().0, 1);
301 assert_eq!(all.last().unwrap().0, CURRENT_VERSION);
302 }
303
304 #[test]
305 fn migration_sql_range_subset() {
306 let subset = migration_sql_range(2, CURRENT_VERSION);
307 assert!(subset.iter().all(|(v, _, _)| *v > 2));
308 let expected = MIGRATIONS.iter().filter(|&&(v, _, _)| v > 2).count();
309 assert_eq!(subset.len(), expected);
310 }
311
312 #[test]
313 fn migration_sql_range_single() {
314 let single = migration_sql_range(2, 3);
315 assert_eq!(single.len(), 1);
316 assert_eq!(single[0].0, 3);
317 assert!(!single[0].2.is_empty());
318 }
319
320 #[test]
321 fn migration_sql_range_empty_when_equal() {
322 let empty = migration_sql_range(CURRENT_VERSION, CURRENT_VERSION);
323 assert!(empty.is_empty());
324 }
325
326 #[test]
327 fn migration_sql_range_empty_when_inverted() {
328 let empty = migration_sql_range(3, 1);
329 assert!(empty.is_empty());
330 }
331
332 #[test]
333 fn migration_sql_range_matches_full() {
334 let full = migration_sql();
335 let ranged = migration_sql_range(0, CURRENT_VERSION);
336 assert_eq!(full.len(), ranged.len());
337 for (f, r) in full.iter().zip(ranged.iter()) {
338 assert_eq!(f.0, r.0);
339 assert_eq!(f.1, r.1);
340 assert_eq!(f.2, r.2);
341 }
342 }
343}