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];
98
99#[derive(Debug, Clone)]
112pub struct MigrationRunner {
113 pool: SqlitePool,
114 migrations: &'static [Migration],
115}
116
117impl MigrationRunner {
118 #[must_use]
120 pub fn new(pool: SqlitePool) -> Self {
121 Self {
122 pool,
123 migrations: BUILTIN_MIGRATIONS,
124 }
125 }
126
127 #[must_use]
131 pub const fn with_migrations(pool: SqlitePool, migrations: &'static [Migration]) -> Self {
132 Self { pool, migrations }
133 }
134
135 async fn ensure_version_table(&self) -> Result<(), sqlx::Error> {
137 sqlx::query(
138 "CREATE TABLE IF NOT EXISTS schema_versions (
139 version INTEGER PRIMARY KEY,
140 description TEXT NOT NULL,
141 applied_at TEXT NOT NULL DEFAULT (datetime('now'))
142 )",
143 )
144 .execute(&self.pool)
145 .await?;
146 Ok(())
147 }
148
149 pub async fn current_version(&self) -> Result<u32, sqlx::Error> {
156 self.ensure_version_table().await?;
157 let row = sqlx::query("SELECT COALESCE(MAX(version), 0) AS v FROM schema_versions")
158 .fetch_one(&self.pool)
159 .await?;
160 let version: i32 = row.get("v");
161 #[allow(clippy::cast_sign_loss)]
162 Ok(version as u32)
163 }
164
165 pub async fn pending_migrations(&self) -> Result<Vec<&Migration>, sqlx::Error> {
171 let current = self.current_version().await?;
172 Ok(self
173 .migrations
174 .iter()
175 .filter(|m| m.version > current)
176 .collect())
177 }
178
179 pub async fn run_pending(&self) -> Result<Vec<u32>, sqlx::Error> {
191 self.ensure_version_table().await?;
192
193 let mut applied = Vec::new();
194
195 for migration in self.migrations {
196 let mut conn = self.pool.acquire().await?;
201 sqlx::query("BEGIN EXCLUSIVE").execute(&mut *conn).await?;
202
203 let row = sqlx::query("SELECT COALESCE(MAX(version), 0) AS v FROM schema_versions")
206 .fetch_one(&mut *conn)
207 .await?;
208 let current: i32 = row.get("v");
209 #[allow(clippy::cast_sign_loss)]
210 let current = current as u32;
211
212 if migration.version <= current {
213 sqlx::query("ROLLBACK").execute(&mut *conn).await?;
215 continue;
216 }
217
218 for statement in migration.sql.split(';') {
222 let trimmed = statement.trim();
223 if trimmed.is_empty() {
224 continue;
225 }
226 sqlx::query(trimmed).execute(&mut *conn).await?;
227 }
228
229 sqlx::query("INSERT INTO schema_versions (version, description) VALUES (?1, ?2)")
231 .bind(migration.version)
232 .bind(migration.description)
233 .execute(&mut *conn)
234 .await?;
235
236 sqlx::query("COMMIT").execute(&mut *conn).await?;
237 applied.push(migration.version);
238 }
239
240 Ok(applied)
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use sqlx::sqlite::SqlitePoolOptions;
248
249 async fn memory_pool() -> SqlitePool {
251 SqlitePoolOptions::new()
252 .max_connections(1)
253 .connect("sqlite::memory:")
254 .await
255 .expect("failed to open in-memory sqlite")
256 }
257
258 #[tokio::test]
259 async fn current_version_starts_at_zero() {
260 let pool = memory_pool().await;
261 let runner = MigrationRunner::new(pool);
262 assert_eq!(runner.current_version().await.unwrap(), 0);
263 }
264
265 #[tokio::test]
266 async fn run_pending_applies_all_builtin_migrations() {
267 let pool = memory_pool().await;
268 let runner = MigrationRunner::new(pool.clone());
269
270 let applied = runner.run_pending().await.unwrap();
271 assert_eq!(applied, vec![1, 2, 3, 4]);
272 assert_eq!(runner.current_version().await.unwrap(), 4);
273
274 let row = sqlx::query("PRAGMA table_info(tasks)")
276 .fetch_all(&pool)
277 .await
278 .unwrap();
279 let columns: Vec<String> = row.iter().map(|r| r.get::<String, _>("name")).collect();
280 assert!(columns.contains(&"id".to_string()));
281 assert!(columns.contains(&"context_id".to_string()));
282 assert!(columns.contains(&"state".to_string()));
283 assert!(columns.contains(&"data".to_string()));
284 assert!(columns.contains(&"updated_at".to_string()));
285 assert!(columns.contains(&"created_at".to_string()));
286 }
287
288 #[tokio::test]
289 async fn run_pending_is_idempotent() {
290 let pool = memory_pool().await;
291 let runner = MigrationRunner::new(pool);
292
293 let first = runner.run_pending().await.unwrap();
294 assert_eq!(first, vec![1, 2, 3, 4]);
295
296 let second = runner.run_pending().await.unwrap();
297 assert!(second.is_empty());
298
299 assert_eq!(runner.current_version().await.unwrap(), 4);
300 }
301
302 #[tokio::test]
303 async fn pending_migrations_returns_unapplied() {
304 let pool = memory_pool().await;
305 let runner = MigrationRunner::new(pool);
306
307 let pending = runner.pending_migrations().await.unwrap();
308 assert_eq!(pending.len(), 4);
309 assert_eq!(pending[0].version, 1);
310 assert_eq!(pending[1].version, 2);
311 assert_eq!(pending[2].version, 3);
312 assert_eq!(pending[3].version, 4);
313
314 runner.run_pending().await.unwrap();
315
316 let pending = runner.pending_migrations().await.unwrap();
317 assert!(pending.is_empty());
318 }
319
320 #[tokio::test]
321 async fn partial_application_tracks_correctly() {
322 let pool = memory_pool().await;
324
325 let v1_only: &[Migration] = &BUILTIN_MIGRATIONS[..1];
326 let runner = MigrationRunner::with_migrations(pool.clone(), v1_only);
329 let applied = runner.run_pending().await.unwrap();
330 assert_eq!(applied, vec![1]);
331 assert_eq!(runner.current_version().await.unwrap(), 1);
332
333 let full_runner = MigrationRunner::new(pool);
335 let pending = full_runner.pending_migrations().await.unwrap();
336 assert_eq!(pending.len(), 3);
337 assert_eq!(pending[0].version, 2);
338 assert_eq!(pending[1].version, 3);
339 assert_eq!(pending[2].version, 4);
340
341 let applied = full_runner.run_pending().await.unwrap();
342 assert_eq!(applied, vec![2, 3, 4]);
343 assert_eq!(full_runner.current_version().await.unwrap(), 4);
344 }
345
346 #[tokio::test]
347 async fn schema_versions_table_records_metadata() {
348 let pool = memory_pool().await;
349 let runner = MigrationRunner::new(pool.clone());
350 runner.run_pending().await.unwrap();
351
352 let rows = sqlx::query(
353 "SELECT version, description, applied_at FROM schema_versions ORDER BY version",
354 )
355 .fetch_all(&pool)
356 .await
357 .unwrap();
358
359 assert_eq!(rows.len(), 4);
360 assert_eq!(rows[0].get::<i32, _>("version"), 1);
361 assert!(!rows[0].get::<String, _>("description").is_empty());
362 assert!(!rows[0].get::<String, _>("applied_at").is_empty());
363 }
364
365 #[tokio::test]
366 async fn updated_at_index_exists_after_migrations() {
367 let pool = memory_pool().await;
368 let runner = MigrationRunner::new(pool.clone());
369 runner.run_pending().await.unwrap();
370
371 let rows = sqlx::query(
372 "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_tasks_updated_at'",
373 )
374 .fetch_all(&pool)
375 .await
376 .unwrap();
377
378 assert_eq!(rows.len(), 1);
379 }
380
381 #[tokio::test]
382 async fn composite_index_exists_after_v3() {
383 let pool = memory_pool().await;
384 let runner = MigrationRunner::new(pool.clone());
385 runner.run_pending().await.unwrap();
386
387 let rows = sqlx::query("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_tasks_context_id_state'")
388 .fetch_all(&pool)
389 .await
390 .unwrap();
391
392 assert_eq!(rows.len(), 1);
393 }
394}