M1: Wire RRULE recurrence expansion into calendar event queries #215

Merged
KofTwentyTwo merged 2 commits from story/m1-rrule-recurrence into dev 2026-07-29 22:35:45 +00:00
3 changed files with 375 additions and 15 deletions

No files matched your search

+43 -7
View File
@@ -47,9 +47,15 @@ impl RecurrenceEngine {
let duration = event.end_time - event.start_time;
// `RRuleSet`'s iterator yields occurrences in strictly increasing date order, so once
// an occurrence passes `window_end_dt` every later one will too -- `take_while` lets
// us stop walking the (possibly unbounded, no-UNTIL/COUNT) series there instead of
// scanning forever for a match that will never come once the series has moved past
// the window. `skip_while` similarly avoids materializing pre-window occurrences.
let results = rrule_set
.into_iter()
.filter(|dt| *dt >= window_start_dt && *dt <= window_end_dt)
.skip_while(|dt| *dt < window_start_dt)
.take_while(|dt| *dt <= window_end_dt)
.take(Self::MAX_EXPANSION_COUNT as usize);
let mut occurrences = Vec::new();
@@ -62,12 +68,11 @@ impl RecurrenceEngine {
occurrences.push(instance);
}
if occurrences.is_empty() {
// Return original if no occurrences fall strictly inside window
Ok(vec![event.clone()])
} else {
Ok(occurrences)
}
// No fallback to the raw master here: an empty result genuinely means no occurrence
// of this recurring event falls inside the requested window (e.g. the window is past
// an `UNTIL`/`COUNT` bound), and returning the master's own out-of-window timestamps
// instead would fabricate a result the caller never asked for.
Ok(occurrences)
}
}
@@ -110,6 +115,37 @@ mod tests {
assert!(results[0].id.contains("evt-weekly_occ_"));
}
#[test]
fn recurring_event_with_no_occurrences_in_window_returns_empty() {
let event = sample_recurring_event();
// Window is entirely before the series' own DTSTART -- no occurrence can fall
// inside it, so this must return no events at all, not the out-of-window master.
let results = RecurrenceEngine::expand_occurrences(&event, 1_600_000_000, 1_600_003_600)
.expect("expansion succeeds");
assert!(results.is_empty());
}
#[test]
fn until_bound_stops_occurrences_past_the_bound() {
let mut event = sample_recurring_event();
// Weekly series ends after its third occurrence (2024-01-15).
event.rrule = Some("FREQ=WEEKLY;INTERVAL=1;UNTIL=20240115T000000Z".to_string());
// Querying a window entirely after the UNTIL bound must produce zero occurrences.
let past_bound = RecurrenceEngine::expand_occurrences(&event, 1_707_350_400, 1_709_251_200)
.expect("expansion succeeds");
assert!(past_bound.is_empty());
// Querying a window spanning the bound only returns occurrences up to and
// including it, never past it.
let spanning_bound = RecurrenceEngine::expand_occurrences(&event, 1704067200, 1706745600)
.expect("expansion succeeds");
assert_eq!(spanning_bound.len(), 3);
for occurrence in &spanning_bound {
assert!(occurrence.start_time <= 1_705_276_800); // 2024-01-15T00:00:00Z
}
}
#[test]
fn invalid_rrule_returns_parse_error() {
let mut event = sample_recurring_event();
+91
View File
@@ -995,6 +995,58 @@ impl DatabaseEngine {
.collect())
}
/// Query every persisted recurring calendar event (non-null, non-empty `rrule`) for
/// `account_id`, with **no** time-window filter -- a recurring master's own stored
/// `start_time`/`end_time` can validly sit long before (or after) any given query
/// window while its expanded occurrences still fall inside it, so callers that need
/// to materialize occurrences for a window must expand every one of the account's
/// recurring masters rather than pre-filtering by the master row's own timestamps.
#[allow(clippy::type_complexity)]
pub async fn list_recurring_calendar_events(
&self,
account_id: &str,
) -> Result<Vec<nuncio_core::model::CalendarEvent>, DatabaseError> {
let rows: Vec<(
String,
String,
String,
String,
i64,
i64,
Option<String>,
Option<String>,
)> = sqlx::query_as(
r#"
SELECT id, account_id, calendar_id, summary, start_time, end_time, rrule, location
FROM calendar_events
WHERE account_id = ? AND rrule IS NOT NULL AND rrule != ''
ORDER BY start_time ASC
"#,
)
.bind(account_id)
.fetch_all(&self.pool)
.await
.map_err(DatabaseError::Query)?;
Ok(rows
.into_iter()
.map(
|(id, account_id, calendar_id, summary, start_time, end_time, rrule, location)| {
nuncio_core::model::CalendarEvent {
id,
account_id,
calendar_id,
summary,
start_time,
end_time,
rrule,
location,
}
},
)
.collect())
}
/// Retrieve a single calendar event by ID.
#[allow(clippy::type_complexity)]
pub async fn get_calendar_event(
@@ -1981,6 +2033,45 @@ mod tests {
assert_eq!(hits[0].id, "evt-db-100");
}
/// `list_recurring_calendar_events` returns only rows with a non-empty `rrule`, for the
/// requested account only, and applies no time-window filter at all -- a master whose own
/// stored `start_time` is far outside any given query window must still come back, since
/// its expanded occurrences may fall inside that window.
#[tokio::test]
async fn list_recurring_calendar_events_filters_by_account_and_rrule_presence() {
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
let mut recurring = sample_calendar_event("evt-recurring", "acct-1");
recurring.rrule = Some("FREQ=WEEKLY;INTERVAL=1".to_string());
recurring.start_time = 1_000; // far outside any window used below
recurring.end_time = 4_600;
engine.save_calendar_event(&recurring).await.unwrap();
let non_recurring = sample_calendar_event("evt-single", "acct-1");
engine.save_calendar_event(&non_recurring).await.unwrap();
let mut other_account_recurring = sample_calendar_event("evt-other-acct", "acct-2");
other_account_recurring.rrule = Some("FREQ=DAILY".to_string());
engine
.save_calendar_event(&other_account_recurring)
.await
.unwrap();
let recurring_for_acct1 = engine
.list_recurring_calendar_events("acct-1")
.await
.expect("list recurring calendar events succeeds");
assert_eq!(recurring_for_acct1.len(), 1);
assert_eq!(recurring_for_acct1[0].id, "evt-recurring");
let recurring_for_acct2 = engine
.list_recurring_calendar_events("acct-2")
.await
.expect("list recurring calendar events succeeds");
assert_eq!(recurring_for_acct2.len(), 1);
assert_eq!(recurring_for_acct2[0].id, "evt-other-acct");
}
/// Re-saving an event with the same ID (an update, not a fresh insert) replaces both the
/// row and its FTS entry rather than duplicating either.
#[tokio::test]
+241 -8
View File
@@ -439,6 +439,47 @@ fn map_calendar_event_to_proto(event: nuncio_core::model::CalendarEvent) -> Cale
}
}
/// Combines an account's in-window events with its recurring masters into the final event
/// list for a `ListEvents` query window, expanding each recurring master into its real
/// occurrences via [`nuncio_cal::RecurrenceEngine::expand_occurrences`] rather than trusting
/// the master row's own stored `start_time`/`end_time` to represent every future instance.
///
/// `recurring` events are never sourced from `all_in_window` for the final result -- only
/// `e.rrule.is_none()` rows from `all_in_window` are kept verbatim -- so a recurring master
/// that also happens to overlap the window in `all_in_window` is never emitted a second time
/// alongside its expanded occurrences.
///
/// A recurring event whose `rrule` fails to parse degrades to the raw stored master (a genuine persisted row -- just unexpanded, not fabricated data) with a warning
/// logged, rather than failing the whole query for one malformed rule among many.
fn expand_events_for_window(
all_in_window: Vec<nuncio_core::model::CalendarEvent>,
recurring: Vec<nuncio_core::model::CalendarEvent>,
start_window: i64,
end_window: i64,
) -> Vec<nuncio_core::model::CalendarEvent> {
let mut events: Vec<nuncio_core::model::CalendarEvent> = all_in_window
.into_iter()
.filter(|e| e.rrule.is_none())
.collect();
for event in recurring {
match nuncio_cal::RecurrenceEngine::expand_occurrences(&event, start_window, end_window) {
Ok(occurrences) => events.extend(occurrences),
Err(e) => {
tracing::warn!(
"Calendar: failed to expand rrule for event '{}', falling back to raw \
stored event: {}",
event.id,
e
);
events.push(event);
}
}
}
events
}
/// Test-only injection point for the daemon's calendar sync backend,
/// mirroring [`MailEngineOverrides`]'s shape.
///
@@ -517,18 +558,25 @@ impl Calendar for CalendarGrpcService {
if req.account_id.is_empty() {
return Err(Status::invalid_argument("account_id is required"));
}
// `DatabaseEngine::list_calendar_events` filters by account_id and
// time window only -- there is no per-calendar-collection filter in
// the store today, so `req.calendar_id` is not applied here. This
// never fabricates data; it genuinely returns every persisted event
// for the account in the requested window, same as `req.calendar_id`
// being absent from the query would.
let events = self
let all_in_window = self
.db
.list_calendar_events(&req.account_id, req.start_window, req.end_window)
.await
.map_err(|e| Status::internal(format!("failed to list calendar events: {e}")))?
.map_err(|e| Status::internal(format!("failed to list calendar events: {e}")))?;
let recurring = self
.db
.list_recurring_calendar_events(&req.account_id)
.await
.map_err(|e| Status::internal(format!("failed to list recurring events: {e}")))?;
let mut events =
expand_events_for_window(all_in_window, recurring, req.start_window, req.end_window);
events.retain(|e| req.calendar_id.is_empty() || e.calendar_id == req.calendar_id);
let events = events
.into_iter()
.map(map_calendar_event_to_proto)
.collect();
@@ -3705,6 +3753,65 @@ mod tests {
assert_eq!(response.events[0].id, "evt-in-window");
}
/// `ListEvents` filters by `calendar_id` when it's non-empty: an event that overlaps the
/// window but belongs to a different calendar collection must not appear in the response.
#[tokio::test]
async fn list_events_excludes_events_from_a_different_calendar_id() {
use nuncio_proto::v1::calendar_client::CalendarClient;
use nuncio_proto::v1::ListEventsRequest;
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("connect ephemeral test db");
db.save_calendar_event(&sample_calendar_event(
"evt-work-cal",
"acct-cal-1",
"cal-work",
1_700_000_000,
1_700_003_600,
))
.await
.expect("save work-calendar event");
db.save_calendar_event(&sample_calendar_event(
"evt-personal-cal",
"acct-cal-1",
"cal-personal",
1_700_000_000,
1_700_003_600,
))
.await
.expect("save personal-calendar event");
let event_bus = Arc::new(EventBus::new());
let secrets = Arc::new(SecretManager::mock());
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
let (addr, _handle) = spawn_test_server_with(
event_bus,
Arc::new(db),
filter_engine,
secrets,
"correct-token",
)
.await;
let mut client = CalendarClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let response = client
.list_events(authed_bearer_request(ListEventsRequest {
account_id: "acct-cal-1".to_string(),
calendar_id: "cal-work".to_string(),
start_window: 1_699_999_000,
end_window: 1_700_004_000,
}))
.await
.expect("list_events succeeds")
.into_inner();
assert_eq!(response.events.len(), 1);
assert_eq!(response.events[0].id, "evt-work-cal");
}
#[tokio::test]
async fn list_events_rejects_empty_account_id() {
use nuncio_proto::v1::calendar_client::CalendarClient;
@@ -3908,4 +4015,130 @@ mod tests {
"Summary for evt-sync-injected-1"
);
}
// ---- expand_events_for_window (pure, no DB/gRPC) ----
fn recurring_event(
id: &str,
rrule: &str,
start: i64,
end: i64,
) -> nuncio_core::model::CalendarEvent {
let mut event = sample_calendar_event(id, "acct-cal-1", "cal-work", start, end);
event.rrule = Some(rrule.to_string());
event
}
/// A weekly event's later occurrence (well past the stored master's own start/end) is
/// returned when the query window covers only that later occurrence -- proving the merge
/// helper materializes real occurrences rather than trusting the master row's own window
/// membership.
#[test]
fn expand_events_for_window_finds_later_occurrence_of_a_recurring_event() {
let master = recurring_event(
"evt-weekly",
"FREQ=WEEKLY;INTERVAL=1",
1_704_067_200, // 2024-01-01T00:00:00Z
1_704_070_800,
);
// Query a window 4 weeks later, which does not overlap the master's own
// start/end at all.
let start_window = 1_706_400_000; // 2024-01-28
let end_window = 1_706_500_000;
let result = expand_events_for_window(Vec::new(), vec![master], start_window, end_window);
assert_eq!(result.len(), 1);
assert!(result[0].start_time >= start_window && result[0].start_time <= end_window);
assert!(result[0].id.starts_with("evt-weekly_occ_"));
}
/// A non-recurring event passed through `all_in_window` is returned unchanged.
#[test]
fn expand_events_for_window_passes_non_recurring_events_through_unchanged() {
let event = sample_calendar_event(
"evt-single",
"acct-cal-1",
"cal-work",
1_700_000_000,
1_700_003_600,
);
let result = expand_events_for_window(
vec![event.clone()],
Vec::new(),
1_699_999_000,
1_700_004_000,
);
assert_eq!(result, vec![event]);
}
/// A recurring master whose own stored start/end also overlaps the query window must
/// never appear twice -- once as the raw row from `all_in_window` and again as an
/// expanded occurrence.
#[test]
fn expand_events_for_window_never_double_counts_a_recurring_master() {
let master = recurring_event(
"evt-weekly",
"FREQ=WEEKLY;INTERVAL=1",
1_704_067_200,
1_704_070_800,
);
// The master's own row also overlaps the window (as `list_calendar_events`
// would genuinely return it), simulating the exact double-count risk.
let result = expand_events_for_window(
vec![master.clone()],
vec![master],
1_704_067_200,
1_704_070_800,
);
assert_eq!(result.len(), 1);
assert!(result[0].id.starts_with("evt-weekly_occ_"));
}
/// A `COUNT`/`UNTIL`-bounded recurrence produces no events for a window entirely past
/// its bound.
#[test]
fn expand_events_for_window_honors_until_bound() {
let master = recurring_event(
"evt-bounded",
"FREQ=WEEKLY;INTERVAL=1;UNTIL=20240115T000000Z",
1_704_067_200,
1_704_070_800,
);
let result = expand_events_for_window(
Vec::new(),
vec![master],
1_709_251_200, // 2024-03-01, well past the UNTIL bound
1_709_337_600,
);
assert!(result.is_empty());
}
/// An event with an unparseable `rrule` degrades to the raw stored master rather than
/// vanishing or failing the whole merge.
#[test]
fn expand_events_for_window_degrades_unparseable_rrule_to_raw_master() {
let master = recurring_event(
"evt-bad-rrule",
"NOT_A_VALID_RRULE",
1_700_000_000,
1_700_003_600,
);
let result = expand_events_for_window(
Vec::new(),
vec![master.clone()],
1_699_999_000,
1_700_004_000,
);
assert_eq!(result, vec![master]);
}
}