From 98b183d2b07c46d25092dc7b7cda050c44fb784b Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sat, 11 Jul 2026 00:11:19 -0500 Subject: [PATCH] feat(storage): cleanup_calendar_events, protects meeting-linked rows CalendarCleanupResult + Store::cleanup_calendar_events(older_than_unix): None deletes every unlinked event ("Delete all"), Some(cutoff) only those starting before it. An event referenced by meetings.calendar_event_id is always kept regardless of age or the "delete all" choice; participant links cascade via the existing ON DELETE CASCADE FK. --- src-tauri/src/storage/mod.rs | 175 +++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs index 935d719..797817a 100644 --- a/src-tauri/src/storage/mod.rs +++ b/src-tauri/src/storage/mod.rs @@ -115,6 +115,15 @@ pub struct CalendarEventDetail { pub participants: Vec, } +/// Result of `cleanup_calendar_events` — `protected` is always reported +/// alongside `deleted` so the UI can show the user their meeting-linked +/// events weren't touched, not just a silent lower-than-expected count. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CalendarCleanupResult { + pub deleted: u32, + pub protected: u32, +} + /// On-disk shape of `summary.json` (`docs/03-data-model.md`). Drafted action /// items here are NOT yet rows in the `action_items` table — the user /// reviews/edits them first; `confirm_action_items` is what persists them @@ -305,6 +314,16 @@ pub trait Store: Send + Sync { /// A single event with its attendees (T6.3/T6.4, FR-CAL-1/3) — backs the /// pre-meeting context panel and the speaker-naming attendee dropdown. async fn get_calendar_event(&self, id: &str) -> Result; + /// Deletes imported calendar events not linked to any meeting — + /// `older_than_unix: None` deletes every unlinked event ("Delete all"), + /// `Some(cutoff)` only those with `starts_at < cutoff`. An event + /// referenced by `meetings.calendar_event_id` is always kept regardless + /// of age (FR-CAL-2 pre-meeting context / FR-CAL-4 continuity would + /// break otherwise). Returns `(deleted, protected)`. + async fn cleanup_calendar_events( + &self, + older_than_unix: Option, + ) -> Result<(u32, u32), StoreError>; /// Link a meeting (current or historical) to a calendar event (T6.3/T6.6, /// FR-CAL-2/4). Errs if either id doesn't exist. Also mirrors the event's /// subject onto the meeting's title when it has one — linking is meant to @@ -1069,6 +1088,60 @@ impl Store for SqliteStore { }) } + async fn cleanup_calendar_events( + &self, + older_than_unix: Option, + ) -> Result<(u32, u32), StoreError> { + // Protected = referenced by any meeting's calendar_event_id, full + // stop — never deleted regardless of age or the "delete all" choice. + let protected: i64 = match older_than_unix { + Some(cutoff) => { + sqlx::query_scalar( + "SELECT COUNT(*) FROM calendar_events + WHERE starts_at < ? + AND id IN (SELECT calendar_event_id FROM meetings WHERE calendar_event_id IS NOT NULL)", + ) + .bind(cutoff) + .fetch_one(&self.pool) + .await? + } + None => { + sqlx::query_scalar( + "SELECT COUNT(*) FROM calendar_events + WHERE id IN (SELECT calendar_event_id FROM meetings WHERE calendar_event_id IS NOT NULL)", + ) + .fetch_one(&self.pool) + .await? + } + }; + + // calendar_event_participants cascades via its ON DELETE CASCADE FK. + let deleted = match older_than_unix { + Some(cutoff) => { + sqlx::query( + "DELETE FROM calendar_events + WHERE starts_at < ? + AND id NOT IN (SELECT calendar_event_id FROM meetings WHERE calendar_event_id IS NOT NULL)", + ) + .bind(cutoff) + .execute(&self.pool) + .await? + .rows_affected() + } + None => { + sqlx::query( + "DELETE FROM calendar_events + WHERE id NOT IN (SELECT calendar_event_id FROM meetings WHERE calendar_event_id IS NOT NULL)", + ) + .execute(&self.pool) + .await? + .rows_affected() + } + }; + + Ok((deleted as u32, protected as u32)) + } + async fn attach_meeting_to_event( &self, meeting_id: &MeetingId, @@ -2137,6 +2210,108 @@ mod tests { ); } + /// Regression for the "14,686 calendar events" bloat report: cleanup + /// must delete unlinked old events while never touching one attached to + /// a recorded meeting, whether age-bounded or "delete all". + #[tokio::test] + async fn cleanup_calendar_events_protects_meeting_linked_rows() { + let store = SqliteStore::connect_in_memory().await.unwrap(); + let event = |id: &str, starts_at: i64| ImportedEvent { + event: CalendarEvent { + id: id.to_string(), + source: "pst".to_string(), + subject: Some(id.to_string()), + organizer: None, + starts_at: Some(starts_at), + ends_at: Some(starts_at + 1_800), + description: None, + raw_uid: Some(id.to_string()), + }, + attendees: vec![], + }; + store + .import_calendar_events(vec![ + event("old-unlinked", 1_000), + event("old-linked", 1_000), + event("recent-unlinked", 1_000_000_000), + ]) + .await + .unwrap(); + + let meeting_id = store + .create_meeting(NewMeeting { + title: "Untitled meeting".to_string(), + calendar_event_id: None, + template_id: None, + language: None, + }) + .await + .unwrap(); + store + .attach_meeting_to_event(&meeting_id, "old-linked") + .await + .unwrap(); + + // Cutoff between the two "old" timestamps and the "recent" one. + let (deleted, protected) = store.cleanup_calendar_events(Some(500_000)).await.unwrap(); + assert_eq!(deleted, 1, "only old-unlinked should go"); + assert_eq!(protected, 1, "old-linked is attached to a meeting"); + + let remaining: Vec = store + .list_calendar_events(None, None) + .await + .unwrap() + .into_iter() + .map(|e| e.id) + .collect(); + assert!(!remaining.contains(&"old-unlinked".to_string())); + assert!(remaining.contains(&"old-linked".to_string())); + assert!(remaining.contains(&"recent-unlinked".to_string())); + } + + #[tokio::test] + async fn cleanup_calendar_events_delete_all_still_protects_linked_rows() { + let store = SqliteStore::connect_in_memory().await.unwrap(); + let event = |id: &str| ImportedEvent { + event: CalendarEvent { + id: id.to_string(), + source: "pst".to_string(), + subject: Some(id.to_string()), + organizer: None, + starts_at: Some(1_000), + ends_at: Some(2_800), + description: None, + raw_uid: Some(id.to_string()), + }, + attendees: vec![], + }; + store + .import_calendar_events(vec![event("unlinked"), event("linked")]) + .await + .unwrap(); + let meeting_id = store + .create_meeting(NewMeeting { + title: "Untitled meeting".to_string(), + calendar_event_id: None, + template_id: None, + language: None, + }) + .await + .unwrap(); + store + .attach_meeting_to_event(&meeting_id, "linked") + .await + .unwrap(); + + let (deleted, protected) = store.cleanup_calendar_events(None).await.unwrap(); + assert_eq!(deleted, 1); + assert_eq!(protected, 1); + assert_eq!( + store.list_calendar_events(None, None).await.unwrap().len(), + 1 + ); + } + #[tokio::test] async fn sync_target_crud_round_trips() { let store = SqliteStore::connect_in_memory().await.unwrap();