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:
1 parent
8828fca805
commit
98b183d2b0
1 file changed
+175
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user