1use crate::error::AwaError;
2use sqlx::postgres::PgConnection;
3use sqlx::PgPool;
4use tracing::info;
5
6pub const CURRENT_VERSION: i32 = 11;
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 11,
47 "Storage transition self-heal: NULL-safe engine resolution and singleton re-seed",
48 &[V11_UP],
49 ),
50];
51
52const V1_UP: &str = include_str!("../migrations/v001_canonical_schema.sql");
53const V2_UP: &str = include_str!("../migrations/v002_runtime_instances.sql");
54const V3_UP: &str = include_str!("../migrations/v003_maintenance_health.sql");
55const V4_UP: &str = include_str!("../migrations/v004_admin_metadata.sql");
56const V5_UP: &str = include_str!("../migrations/v005_admin_metadata_stmt_triggers.sql");
57const V6_UP: &str = include_str!("../migrations/v006_remove_hot_table_triggers.sql");
58const V7_UP: &str = include_str!("../migrations/v007_backoff_interval_fix.sql");
59const V9_UP: &str = include_str!("../migrations/v009_descriptors.sql");
60const V10_UP: &str = include_str!("../migrations/v010_storage_transition_prep.sql");
61const V11_UP: &str = include_str!("../migrations/v011_storage_transition_self_heal.sql");
62
63fn normalize_legacy_version(old_version: i32) -> i32 {
67 match old_version {
68 v if v >= 6 => 4, 5 => 3, 4 => 2, 3 => 1, _ => 0, }
74}
75
76pub async fn run(pool: &PgPool) -> Result<(), AwaError> {
86 let lock_key: i64 = 0x4157_415f_4d49_4752; let mut conn = pool.acquire().await?;
88 sqlx::query("SELECT pg_advisory_lock($1)")
89 .bind(lock_key)
90 .execute(&mut *conn)
91 .await?;
92
93 let result = run_inner(&mut conn).await;
94
95 let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
96 .bind(lock_key)
97 .execute(&mut *conn)
98 .await;
99
100 result
101}
102
103async fn run_inner(conn: &mut PgConnection) -> Result<(), AwaError> {
104 let has_schema: bool =
105 sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM pg_namespace WHERE nspname = 'awa')")
106 .fetch_one(&mut *conn)
107 .await?;
108
109 let current = if has_schema {
110 current_version_conn(conn).await?
111 } else {
112 0
113 };
114
115 if !(has_schema && current == CURRENT_VERSION) {
116 for &(version, description, steps) in MIGRATIONS {
117 if version <= current {
118 continue;
119 }
120 info!(version, description, "Applying migration");
121 for step in steps {
122 sqlx::raw_sql(step).execute(&mut *conn).await?;
123 }
124 info!(version, "Migration applied");
125 }
126 } else {
127 info!(version = current, "Schema is up to date");
128 }
129
130 let has_refresh: bool = sqlx::query_scalar(
135 "SELECT EXISTS(SELECT 1 FROM pg_proc WHERE proname = 'refresh_admin_metadata' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'awa'))",
136 )
137 .fetch_one(&mut *conn)
138 .await?;
139 if has_refresh {
140 let _ = sqlx::raw_sql(
146 "BEGIN; SET LOCAL statement_timeout = '5s'; SELECT awa.refresh_admin_metadata(); COMMIT;",
147 )
148 .execute(&mut *conn)
149 .await;
150 }
151
152 Ok(())
153}
154
155pub async fn current_version(pool: &PgPool) -> Result<i32, AwaError> {
157 let mut conn = pool.acquire().await?;
158 current_version_conn(&mut conn).await
159}
160
161async fn current_version_conn(conn: &mut PgConnection) -> Result<i32, AwaError> {
162 let has_schema: bool =
163 sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM pg_namespace WHERE nspname = 'awa')")
164 .fetch_one(&mut *conn)
165 .await?;
166
167 if !has_schema {
168 return Ok(0);
169 }
170
171 let has_table: bool = sqlx::query_scalar(
172 "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'awa' AND table_name = 'schema_version')",
173 )
174 .fetch_one(&mut *conn)
175 .await?;
176
177 if !has_table {
178 return Ok(0);
179 }
180
181 let version: Option<i32> = sqlx::query_scalar("SELECT MAX(version) FROM awa.schema_version")
182 .fetch_one(&mut *conn)
183 .await?;
184
185 let raw_version = version.unwrap_or(0);
186
187 if (1..=CURRENT_VERSION).contains(&raw_version) {
190 let has_admin_tables: bool = sqlx::query_scalar(
193 "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'awa' AND table_name = 'queue_state_counts')",
194 )
195 .fetch_one(&mut *conn)
196 .await
197 .unwrap_or(false);
198
199 if raw_version >= 4 && has_admin_tables {
202 return Ok(raw_version);
203 }
204 if raw_version <= 3 {
206 let has_runtime: bool = sqlx::query_scalar(
207 "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'awa' AND table_name = 'runtime_instances')",
208 )
209 .fetch_one(&mut *conn)
210 .await
211 .unwrap_or(false);
212 if (raw_version >= 2 && has_runtime) || raw_version == 1 {
214 return Ok(raw_version);
215 }
216 }
217 }
218
219 let has_legacy_high: bool =
223 sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM awa.schema_version WHERE version >= 6)")
224 .fetch_one(&mut *conn)
225 .await
226 .unwrap_or(false);
227
228 let has_admin_metadata: bool = sqlx::query_scalar(
229 "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'awa' AND table_name = 'queue_state_counts')",
230 )
231 .fetch_one(&mut *conn)
232 .await
233 .unwrap_or(false);
234
235 let is_legacy_v5_only = raw_version == 5 && !has_legacy_high && !has_admin_metadata;
236 let is_legacy_v4_only = raw_version == 4 && !has_legacy_high && !has_admin_metadata;
237
238 let is_legacy_v3_only = raw_version == 3
241 && !has_legacy_high
242 && {
243 let has_runtime: bool = sqlx::query_scalar(
244 "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'awa' AND table_name = 'runtime_instances')",
245 )
246 .fetch_one(&mut *conn)
247 .await
248 .unwrap_or(false);
249 !has_runtime
250 };
251
252 if has_legacy_high || is_legacy_v5_only || is_legacy_v4_only || is_legacy_v3_only {
253 let normalized = normalize_legacy_version(raw_version);
254 info!(
255 old_version = raw_version,
256 new_version = normalized,
257 "Normalizing legacy version numbering"
258 );
259 sqlx::query("DELETE FROM awa.schema_version WHERE version >= 3")
261 .execute(&mut *conn)
262 .await?;
263 for &(v, desc, _) in MIGRATIONS {
264 if v <= normalized {
265 sqlx::query(
266 "INSERT INTO awa.schema_version (version, description) VALUES ($1, $2) ON CONFLICT (version) DO NOTHING",
267 )
268 .bind(v)
269 .bind(desc)
270 .execute(&mut *conn)
271 .await?;
272 }
273 }
274 return Ok(normalized);
275 }
276
277 Ok(raw_version)
278}
279
280pub fn migration_sql() -> Vec<(i32, &'static str, String)> {
282 MIGRATIONS
283 .iter()
284 .map(|&(v, d, steps)| (v, d, steps.join("\n")))
285 .collect()
286}
287
288pub fn migration_sql_range(from: i32, to: i32) -> Vec<(i32, &'static str, String)> {
291 MIGRATIONS
292 .iter()
293 .filter(|&&(v, _, _)| v > from && v <= to)
294 .map(|&(v, d, steps)| (v, d, steps.join("\n")))
295 .collect()
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn migration_sql_range_all() {
304 let all = migration_sql_range(0, CURRENT_VERSION);
305 assert_eq!(all.len(), MIGRATIONS.len());
306 assert_eq!(all.first().unwrap().0, 1);
307 assert_eq!(all.last().unwrap().0, CURRENT_VERSION);
308 }
309
310 #[test]
311 fn migration_sql_range_subset() {
312 let subset = migration_sql_range(2, CURRENT_VERSION);
313 assert!(subset.iter().all(|(v, _, _)| *v > 2));
314 let expected = MIGRATIONS.iter().filter(|&&(v, _, _)| v > 2).count();
315 assert_eq!(subset.len(), expected);
316 }
317
318 #[test]
319 fn migration_sql_range_single() {
320 let single = migration_sql_range(2, 3);
321 assert_eq!(single.len(), 1);
322 assert_eq!(single[0].0, 3);
323 assert!(!single[0].2.is_empty());
324 }
325
326 #[test]
327 fn migration_sql_range_empty_when_equal() {
328 let empty = migration_sql_range(CURRENT_VERSION, CURRENT_VERSION);
329 assert!(empty.is_empty());
330 }
331
332 #[test]
333 fn migration_sql_range_empty_when_inverted() {
334 let empty = migration_sql_range(3, 1);
335 assert!(empty.is_empty());
336 }
337
338 #[test]
339 fn migration_sql_range_matches_full() {
340 let full = migration_sql();
341 let ranged = migration_sql_range(0, CURRENT_VERSION);
342 assert_eq!(full.len(), ranged.len());
343 for (f, r) in full.iter().zip(ranged.iter()) {
344 assert_eq!(f.0, r.0);
345 assert_eq!(f.1, r.1);
346 assert_eq!(f.2, r.2);
347 }
348 }
349}