a2a_protocol_server/store/
migration.rs1use sqlx::sqlite::SqlitePool;
46use sqlx::Row;
47
48#[derive(Debug, Clone)]
53pub struct Migration {
54 pub version: u32,
57 pub description: &'static str,
59 pub sql: &'static str,
62}
63
64pub static BUILTIN_MIGRATIONS: &[Migration] = &[
68 Migration {
69 version: 1,
70 description: "Initial schema: tasks table with context_id and state indexes",
71 sql: "\
72CREATE TABLE IF NOT EXISTS tasks (
73 id TEXT PRIMARY KEY,
74 context_id TEXT NOT NULL,
75 state TEXT NOT NULL,
76 data TEXT NOT NULL,
77 updated_at TEXT NOT NULL DEFAULT (datetime('now'))
78);
79CREATE INDEX IF NOT EXISTS idx_tasks_context_id ON tasks(context_id);
80CREATE INDEX IF NOT EXISTS idx_tasks_state ON tasks(state);",
81 },
82 Migration {
83 version: 2,
84 description: "Add created_at column to tasks table",
85 sql: "ALTER TABLE tasks ADD COLUMN created_at TEXT NOT NULL DEFAULT (datetime('now'));",
86 },
87 Migration {
88 version: 3,
89 description: "Add composite index on (context_id, state) for combined filter queries",
90 sql: "CREATE INDEX IF NOT EXISTS idx_tasks_context_id_state ON tasks(context_id, state);",
91 },
92 Migration {
93 version: 4,
94 description: "Add (updated_at, id) index for most-recently-updated-first list ordering",
95 sql: "CREATE INDEX IF NOT EXISTS idx_tasks_updated_at ON tasks(updated_at DESC, id DESC);",
96 },
97 Migration {
98 version: 5,
99 description: "Add task_artifact_appends: the journal streaming appends are written to",
100 sql: super::sqlite_store::journal::CREATE_TABLE_SQL,
108 },
109];
110
111#[derive(Debug, Clone)]
124pub struct MigrationRunner {
125 pool: SqlitePool,
126 migrations: &'static [Migration],
127}
128
129impl MigrationRunner {
130 #[must_use]
132 pub fn new(pool: SqlitePool) -> Self {
133 Self {
134 pool,
135 migrations: BUILTIN_MIGRATIONS,
136 }
137 }
138
139 #[must_use]
143 pub const fn with_migrations(pool: SqlitePool, migrations: &'static [Migration]) -> Self {
144 Self { pool, migrations }
145 }
146
147 async fn ensure_version_table(&self) -> Result<(), sqlx::Error> {
149 sqlx::query(
150 "CREATE TABLE IF NOT EXISTS schema_versions (
151 version INTEGER PRIMARY KEY,
152 description TEXT NOT NULL,
153 applied_at TEXT NOT NULL DEFAULT (datetime('now'))
154 )",
155 )
156 .execute(&self.pool)
157 .await?;
158 Ok(())
159 }
160
161 pub async fn current_version(&self) -> Result<u32, sqlx::Error> {
168 self.ensure_version_table().await?;
169 let row = sqlx::query("SELECT COALESCE(MAX(version), 0) AS v FROM schema_versions")
170 .fetch_one(&self.pool)
171 .await?;
172 let version: i32 = row.get("v");
173 #[allow(clippy::cast_sign_loss)]
174 Ok(version as u32)
175 }
176
177 pub async fn pending_migrations(&self) -> Result<Vec<&Migration>, sqlx::Error> {
183 let current = self.current_version().await?;
184 Ok(self
185 .migrations
186 .iter()
187 .filter(|m| m.version > current)
188 .collect())
189 }
190
191 pub async fn run_pending(&self) -> Result<Vec<u32>, sqlx::Error> {
203 self.ensure_version_table().await?;
204
205 let mut applied = Vec::new();
206
207 for migration in self.migrations {
208 let mut conn = self.pool.acquire().await?;
213 sqlx::query("BEGIN EXCLUSIVE").execute(&mut *conn).await?;
214
215 let row = sqlx::query("SELECT COALESCE(MAX(version), 0) AS v FROM schema_versions")
218 .fetch_one(&mut *conn)
219 .await?;
220 let current: i32 = row.get("v");
221 #[allow(clippy::cast_sign_loss)]
222 let current = current as u32;
223
224 if migration.version <= current {
225 sqlx::query("ROLLBACK").execute(&mut *conn).await?;
227 continue;
228 }
229
230 for statement in migration.sql.split(';') {
234 let trimmed = statement.trim();
235 if trimmed.is_empty() {
236 continue;
237 }
238 sqlx::query(trimmed).execute(&mut *conn).await?;
239 }
240
241 sqlx::query("INSERT INTO schema_versions (version, description) VALUES (?1, ?2)")
243 .bind(migration.version)
244 .bind(migration.description)
245 .execute(&mut *conn)
246 .await?;
247
248 sqlx::query("COMMIT").execute(&mut *conn).await?;
249 applied.push(migration.version);
250 }
251
252 Ok(applied)
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use sqlx::sqlite::SqlitePoolOptions;
260
261 async fn memory_pool() -> SqlitePool {
263 SqlitePoolOptions::new()
264 .max_connections(1)
265 .connect("sqlite::memory:")
266 .await
267 .expect("failed to open in-memory sqlite")
268 }
269
270 #[tokio::test]
271 async fn current_version_starts_at_zero() {
272 let pool = memory_pool().await;
273 let runner = MigrationRunner::new(pool);
274 assert_eq!(runner.current_version().await.unwrap(), 0);
275 }
276
277 #[tokio::test]
278 async fn run_pending_applies_all_builtin_migrations() {
279 let pool = memory_pool().await;
280 let runner = MigrationRunner::new(pool.clone());
281
282 let expected: Vec<u32> = BUILTIN_MIGRATIONS.iter().map(|m| m.version).collect();
287 let latest = *expected.last().expect("there is at least one migration");
288
289 let applied = runner.run_pending().await.unwrap();
290 assert_eq!(applied, expected);
291 assert_eq!(runner.current_version().await.unwrap(), latest);
292
293 let journal = sqlx::query("PRAGMA table_info(task_artifact_appends)")
298 .fetch_all(&pool)
299 .await
300 .unwrap();
301 assert!(
302 !journal.is_empty(),
303 "a migrated schema must carry task_artifact_appends"
304 );
305
306 let row = sqlx::query("PRAGMA table_info(tasks)")
308 .fetch_all(&pool)
309 .await
310 .unwrap();
311 let columns: Vec<String> = row.iter().map(|r| r.get::<String, _>("name")).collect();
312 assert!(columns.contains(&"id".to_string()));
313 assert!(columns.contains(&"context_id".to_string()));
314 assert!(columns.contains(&"state".to_string()));
315 assert!(columns.contains(&"data".to_string()));
316 assert!(columns.contains(&"updated_at".to_string()));
317 assert!(columns.contains(&"created_at".to_string()));
318 }
319
320 #[tokio::test]
321 async fn run_pending_is_idempotent() {
322 let pool = memory_pool().await;
323 let runner = MigrationRunner::new(pool);
324
325 let first = runner.run_pending().await.unwrap();
326 let expected: Vec<u32> = BUILTIN_MIGRATIONS.iter().map(|m| m.version).collect();
327 assert_eq!(first, expected);
328
329 let second = runner.run_pending().await.unwrap();
330 assert_eq!(second, [] as [u32; 0]);
331
332 let latest = BUILTIN_MIGRATIONS
333 .last()
334 .expect("there is at least one migration")
335 .version;
336 assert_eq!(runner.current_version().await.unwrap(), latest);
337 }
338
339 #[tokio::test]
340 async fn pending_migrations_returns_unapplied() {
341 let pool = memory_pool().await;
342 let runner = MigrationRunner::new(pool);
343
344 let pending = runner.pending_migrations().await.unwrap();
345 assert_eq!(pending.len(), BUILTIN_MIGRATIONS.len());
346 assert_eq!(pending[0].version, 1);
347 assert_eq!(pending[1].version, 2);
348 assert_eq!(pending[2].version, 3);
349 assert_eq!(pending[3].version, 4);
350
351 runner.run_pending().await.unwrap();
352
353 let pending = runner.pending_migrations().await.unwrap();
354 assert!(pending.is_empty());
355 }
356
357 #[tokio::test]
358 async fn partial_application_tracks_correctly() {
359 let pool = memory_pool().await;
361
362 let v1_only: &[Migration] = &BUILTIN_MIGRATIONS[..1];
363 let runner = MigrationRunner::with_migrations(pool.clone(), v1_only);
366 let applied = runner.run_pending().await.unwrap();
367 assert_eq!(applied, vec![1]);
368 assert_eq!(runner.current_version().await.unwrap(), 1);
369
370 let after_v1: Vec<u32> = BUILTIN_MIGRATIONS
374 .iter()
375 .map(|m| m.version)
376 .filter(|v| *v > 1)
377 .collect();
378 let latest = *after_v1.last().expect("there is more than one migration");
379
380 let full_runner = MigrationRunner::new(pool);
381 let pending = full_runner.pending_migrations().await.unwrap();
382 assert_eq!(pending.len(), after_v1.len());
383 assert_eq!(pending[0].version, 2);
384
385 let applied = full_runner.run_pending().await.unwrap();
386 assert_eq!(applied, after_v1);
387 assert_eq!(full_runner.current_version().await.unwrap(), latest);
388 }
389
390 #[tokio::test]
391 async fn schema_versions_table_records_metadata() {
392 let pool = memory_pool().await;
393 let runner = MigrationRunner::new(pool.clone());
394 runner.run_pending().await.unwrap();
395
396 let rows = sqlx::query(
397 "SELECT version, description, applied_at FROM schema_versions ORDER BY version",
398 )
399 .fetch_all(&pool)
400 .await
401 .unwrap();
402
403 assert_eq!(rows.len(), BUILTIN_MIGRATIONS.len());
404 assert_eq!(rows[0].get::<i32, _>("version"), 1);
405 assert_ne!(rows[0].get::<String, _>("description"), "");
406 assert_ne!(rows[0].get::<String, _>("applied_at"), "");
407 }
408
409 #[tokio::test]
410 async fn updated_at_index_exists_after_migrations() {
411 let pool = memory_pool().await;
412 let runner = MigrationRunner::new(pool.clone());
413 runner.run_pending().await.unwrap();
414
415 let rows = sqlx::query(
416 "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_tasks_updated_at'",
417 )
418 .fetch_all(&pool)
419 .await
420 .unwrap();
421
422 assert_eq!(rows.len(), 1);
423 }
424
425 #[tokio::test]
426 async fn composite_index_exists_after_v3() {
427 let pool = memory_pool().await;
428 let runner = MigrationRunner::new(pool.clone());
429 runner.run_pending().await.unwrap();
430
431 let rows = sqlx::query("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_tasks_context_id_state'")
432 .fetch_all(&pool)
433 .await
434 .unwrap();
435
436 assert_eq!(rows.len(), 1);
437 }
438}