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.
This commit is contained in:
iamdoubz committed 2026-07-11 00:11:19 -05:00
1 parent 8828fca805
commit 98b183d2b0
1 file changed
+175
+175
View File
@@ -115,6 +115,15 @@ pub struct CalendarEventDetail {
pub participants: Vec<Participant>,
}
/// 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<CalendarEventDetail, StoreError>;
/// 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<i64>,
) -> 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<i64>,
) -> 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<String> = 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();