diff --git a/crates/storage/src/migrations.rs b/crates/storage/src/migrations.rs index aa286d9..20ff74f 100644 --- a/crates/storage/src/migrations.rs +++ b/crates/storage/src/migrations.rs @@ -463,6 +463,20 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[ ALTER TABLE transcripts ADD COLUMN llm_tags TEXT NOT NULL DEFAULT ''; "#, ), + ( + 15, + "transcripts: composite (profile_id, created_at DESC) index", + r#" + -- Performance index for the dominant transcripts query path: + -- WHERE profile_id = ? ORDER BY created_at DESC LIMIT ? + -- The standalone idx_transcripts_profile_id and idx_transcripts_created + -- forced SQLite to either filter by profile then sort, or scan the date + -- index and filter — fine at hundreds of rows, painful past a few thousand. + -- A composite index covers both predicates in one ordered seek. + CREATE INDEX IF NOT EXISTS idx_transcripts_profile_created + ON transcripts(profile_id, created_at DESC); + "#, + ), ]; /// Split SQL into individual statements, respecting BEGIN...END trigger blocks. @@ -612,7 +626,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 14); + assert_eq!(count, 15); sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')") .execute(&pool) @@ -631,7 +645,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 14); + assert_eq!(count, 15); } #[tokio::test] @@ -1124,4 +1138,48 @@ mod tests { .expect("query"); assert_eq!(auto, 0, "pre-existing completed rows must default to 0"); } + + #[tokio::test] + async fn migration_v15_creates_profile_created_index() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("pool"); + run_migrations(&pool).await.expect("migrate"); + + // Index exists by name. + let names: Vec = sqlx::query_scalar( + "SELECT name FROM sqlite_master \ + WHERE type = 'index' AND tbl_name = 'transcripts'", + ) + .fetch_all(&pool) + .await + .expect("read indexes"); + assert!( + names.iter().any(|n| n == "idx_transcripts_profile_created"), + "expected composite (profile_id, created_at) index, got {names:?}", + ); + + // Query planner picks the composite index for the dominant + // profile-scoped, date-ordered list query. EXPLAIN QUERY PLAN + // returns (id, parent, notused, detail) — we want detail. + let plan_rows = sqlx::query( + "EXPLAIN QUERY PLAN \ + SELECT id FROM transcripts \ + WHERE profile_id = ? ORDER BY created_at DESC LIMIT 50", + ) + .bind(crate::DEFAULT_PROFILE_ID) + .fetch_all(&pool) + .await + .expect("explain"); + let plan: Vec = plan_rows + .iter() + .map(|r| r.get::("detail")) + .collect(); + assert!( + plan.iter().any(|row| row.contains("idx_transcripts_profile_created")), + "planner should use the composite index, got plan: {plan:?}", + ); + } }