Added DatabaseEngine::list_recurring_calendar_events(account_id) — returns every persisted event with a non-empty rrule for the account, with no time-window filter (a recurring master's own stored timestamps can sit outside a query window while its occurrences fall inside it).
Added a pure, directly-unit-testable merge helper expand_events_for_window(all_in_window, recurring, start_window, end_window) in crates/nunciod/src/grpc.rs: keeps non-recurring rows from all_in_window verbatim, and for every event in recurring calls nuncio_cal::RecurrenceEngine::expand_occurrences to materialize real occurrences inside the window. Recurring rows are always sourced from recurring/expansion, never from all_in_window, so a recurring master that also happens to overlap the window in the raw query is never double-counted.
Wired the helper into CalendarGrpcService::list_events, which now fetches both list_calendar_events and list_recurring_calendar_events and merges them.
An rrule that fails to parse degrades to the raw stored master with tracing::warn!, mirroring the pattern in FiltersGrpcService::reload_engine_from_store — a single bad rule never fails the whole ListEvents call.
Applied the previously-ignored calendar_id filter (deferred scope from the M1-S1 review of #185): list_events now retains only events matching req.calendar_id when it's non-empty.
Fixed a genuine correctness/safety gap in RecurrenceEngine::expand_occurrences while wiring it in: it previously fell back to returning the raw, potentially out-of-window master event whenever zero occurrences matched the window (e.g. a window entirely past an UNTIL/COUNT bound), which would have fabricated a result outside the requested window once this was wired into list_events. Also, filtering before take() on an unbounded (no UNTIL/COUNT) series whose occurrences never enter a past/pre-series window could scan forever. Replaced with skip_while/take_while over the RRuleSet's monotonically increasing iterator, which bounds the walk and returns a genuinely empty result when nothing matches.
An out-of-window master with in-window occurrences is handled by never filtering recurring by the window at the store layer — every recurring master for the account is always fetched and always passed through expand_occurrences, which does the real per-occurrence windowing.
nuncio-cal: recurring_event_with_no_occurrences_in_window_returns_empty, until_bound_stops_occurrences_past_the_bound (the engine-level correctness fix above).
nunciod::grpc: pure unit tests on expand_events_for_window — later-occurrence-of-a-recurring-event found, non-recurring passthrough unchanged, no double-counting a master that also overlaps the window, UNTIL bound honored, unparseable rrule degrades to the raw master. Plus an RPC-level test list_events_excludes_events_from_a_different_calendar_id for the calendar_id filter.
Pre-commit hook (fmt/clippy/test-all) also ran and passed on the commit.
Decisions / notes for PO review
No proto change — ListEventsResponse.events already carries the expanded occurrences; not a [PROTO] story.
nuncio-store gained no dependency on nuncio-cal; expansion happens entirely at the nunciod gRPC layer, per the issue's layering guidance.
The RecurrenceEngine::expand_occurrences behavior change (removing the "return raw master on empty match" fallback, switching to skip_while/take_while) is a real engine-level fix, not just RPC wiring — flagging it explicitly since the issue said "no change expected" unless a genuine gap was found. Happy to discuss if a different tradeoff is preferred.
Closes #186.
## Summary
- Added `DatabaseEngine::list_recurring_calendar_events(account_id)` — returns every persisted event with a non-empty `rrule` for the account, with **no** time-window filter (a recurring master's own stored timestamps can sit outside a query window while its occurrences fall inside it).
- Added a pure, directly-unit-testable merge helper `expand_events_for_window(all_in_window, recurring, start_window, end_window)` in `crates/nunciod/src/grpc.rs`: keeps non-recurring rows from `all_in_window` verbatim, and for every event in `recurring` calls `nuncio_cal::RecurrenceEngine::expand_occurrences` to materialize real occurrences inside the window. Recurring rows are always sourced from `recurring`/expansion, never from `all_in_window`, so a recurring master that also happens to overlap the window in the raw query is never double-counted.
- Wired the helper into `CalendarGrpcService::list_events`, which now fetches both `list_calendar_events` and `list_recurring_calendar_events` and merges them.
- An `rrule` that fails to parse degrades to the raw stored master with `tracing::warn!`, mirroring the pattern in `FiltersGrpcService::reload_engine_from_store` — a single bad rule never fails the whole `ListEvents` call.
- Applied the previously-ignored `calendar_id` filter (deferred scope from the M1-S1 review of #185): `list_events` now retains only events matching `req.calendar_id` when it's non-empty.
- **Fixed a genuine correctness/safety gap in `RecurrenceEngine::expand_occurrences`** while wiring it in: it previously fell back to returning the raw, potentially out-of-window master event whenever zero occurrences matched the window (e.g. a window entirely past an `UNTIL`/`COUNT` bound), which would have fabricated a result outside the requested window once this was wired into `list_events`. Also, filtering before `take()` on an unbounded (no `UNTIL`/`COUNT`) series whose occurrences never enter a past/pre-series window could scan forever. Replaced with `skip_while`/`take_while` over the `RRuleSet`'s monotonically increasing iterator, which bounds the walk and returns a genuinely empty result when nothing matches.
## How recurrence expansion is wired
`list_events` now does:
```rust
let all_in_window = db.list_calendar_events(&req.account_id, req.start_window, req.end_window).await?;
let recurring = db.list_recurring_calendar_events(&req.account_id).await?;
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);
```
An out-of-window master with in-window occurrences is handled by never filtering `recurring` by the window at the store layer — every recurring master for the account is always fetched and always passed through `expand_occurrences`, which does the real per-occurrence windowing.
## Tests added
- `nuncio-store`: `list_recurring_calendar_events_filters_by_account_and_rrule_presence` (rrule-null exclusion, account scoping).
- `nuncio-cal`: `recurring_event_with_no_occurrences_in_window_returns_empty`, `until_bound_stops_occurrences_past_the_bound` (the engine-level correctness fix above).
- `nunciod::grpc`: pure unit tests on `expand_events_for_window` — later-occurrence-of-a-recurring-event found, non-recurring passthrough unchanged, no double-counting a master that also overlaps the window, `UNTIL` bound honored, unparseable `rrule` degrades to the raw master. Plus an RPC-level test `list_events_excludes_events_from_a_different_calendar_id` for the calendar_id filter.
## Gate evidence
```
cargo fmt --all -- --check -> clean
cargo check-all -> clean (clippy --all-targets --workspace -D warnings, 0 warnings)
cargo test-all -> 0 failed, 0 ignored across the workspace
nuncio-cal: 25 passed
nuncio-store: 51 passed
nunciod: 91 passed (unit) + calendar_e2e_test 1 passed + daemon_e2e_test 1 + grpc_auth_test 3 + spine_e2e_test 1
(all other workspace crates unaffected, all green)
```
Pre-commit hook (fmt/clippy/test-all) also ran and passed on the commit.
## Decisions / notes for PO review
- No proto change — `ListEventsResponse.events` already carries the expanded occurrences; not a `[PROTO]` story.
- `nuncio-store` gained no dependency on `nuncio-cal`; expansion happens entirely at the `nunciod` gRPC layer, per the issue's layering guidance.
- The `RecurrenceEngine::expand_occurrences` behavior change (removing the "return raw master on empty match" fallback, switching to `skip_while`/`take_while`) is a real engine-level fix, not just RPC wiring — flagging it explicitly since the issue said "no change expected" unless a genuine gap was found. Happy to discuss if a different tradeoff is preferred.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #186.
Summary
DatabaseEngine::list_recurring_calendar_events(account_id)— returns every persisted event with a non-emptyrrulefor the account, with no time-window filter (a recurring master's own stored timestamps can sit outside a query window while its occurrences fall inside it).expand_events_for_window(all_in_window, recurring, start_window, end_window)incrates/nunciod/src/grpc.rs: keeps non-recurring rows fromall_in_windowverbatim, and for every event inrecurringcallsnuncio_cal::RecurrenceEngine::expand_occurrencesto materialize real occurrences inside the window. Recurring rows are always sourced fromrecurring/expansion, never fromall_in_window, so a recurring master that also happens to overlap the window in the raw query is never double-counted.CalendarGrpcService::list_events, which now fetches bothlist_calendar_eventsandlist_recurring_calendar_eventsand merges them.rrulethat fails to parse degrades to the raw stored master withtracing::warn!, mirroring the pattern inFiltersGrpcService::reload_engine_from_store— a single bad rule never fails the wholeListEventscall.calendar_idfilter (deferred scope from the M1-S1 review of #185):list_eventsnow retains only events matchingreq.calendar_idwhen it's non-empty.RecurrenceEngine::expand_occurrenceswhile wiring it in: it previously fell back to returning the raw, potentially out-of-window master event whenever zero occurrences matched the window (e.g. a window entirely past anUNTIL/COUNTbound), which would have fabricated a result outside the requested window once this was wired intolist_events. Also, filtering beforetake()on an unbounded (noUNTIL/COUNT) series whose occurrences never enter a past/pre-series window could scan forever. Replaced withskip_while/take_whileover theRRuleSet's monotonically increasing iterator, which bounds the walk and returns a genuinely empty result when nothing matches.How recurrence expansion is wired
list_eventsnow does:An out-of-window master with in-window occurrences is handled by never filtering
recurringby the window at the store layer — every recurring master for the account is always fetched and always passed throughexpand_occurrences, which does the real per-occurrence windowing.Tests added
nuncio-store:list_recurring_calendar_events_filters_by_account_and_rrule_presence(rrule-null exclusion, account scoping).nuncio-cal:recurring_event_with_no_occurrences_in_window_returns_empty,until_bound_stops_occurrences_past_the_bound(the engine-level correctness fix above).nunciod::grpc: pure unit tests onexpand_events_for_window— later-occurrence-of-a-recurring-event found, non-recurring passthrough unchanged, no double-counting a master that also overlaps the window,UNTILbound honored, unparseablerruledegrades to the raw master. Plus an RPC-level testlist_events_excludes_events_from_a_different_calendar_idfor the calendar_id filter.Gate evidence
Pre-commit hook (fmt/clippy/test-all) also ran and passed on the commit.
Decisions / notes for PO review
ListEventsResponse.eventsalready carries the expanded occurrences; not a[PROTO]story.nuncio-storegained no dependency onnuncio-cal; expansion happens entirely at thenunciodgRPC layer, per the issue's layering guidance.RecurrenceEngine::expand_occurrencesbehavior change (removing the "return raw master on empty match" fallback, switching toskip_while/take_while) is a real engine-level fix, not just RPC wiring — flagging it explicitly since the issue said "no change expected" unless a genuine gap was found. Happy to discuss if a different tradeoff is preferred.