Skip to content

fix(jmap): correctness fixes + dedup from June 2026 review#688

Open
tobixen wants to merge 1 commit into
masterfrom
jmap-code-review-fixes
Open

fix(jmap): correctness fixes + dedup from June 2026 review#688
tobixen wants to merge 1 commit into
masterfrom
jmap-code-review-fixes

Conversation

@tobixen

@tobixen tobixen commented Jun 19, 2026

Copy link
Copy Markdown
Member

I managed to run a full code review of the caldav library with the Claude Fable model before it was yanked, and I've had Claude Sonnet and Claude Opus helping me fixing the things. I'm still busy reviewing, rewording, squashing similar commits together, etc.

The experimental JMAP support was written by @SashankBhamidi - perhaps you could help me reviewing this particular changeset? (or rewrite it completely if you prefer that).

The rest of this message is AI-generated


JMAP fixes from the June 2026 code review

This consolidates the JMAP-related findings from the full codebase review
(docs/design/FULL_CODE_REVIEW_2026-06.md) into a single commit, so the JMAP
backend can be fixed on master independently of the larger v3.3.0 work.

Correctness fixes

  • §1.3 create_task() was missing the empty-created-dict guard that
    create_event() already had → bare KeyError instead of JMAPMethodError
    (sync + async).
  • §4.1 jscal_to_ical: override child VEVENT with no start patch key took
    DTSTART from the master instead of the override key — relocated every
    title-only override to the master's first occurrence.
  • §4.2 jscal_to_ical: EXDATE / RECURRENCE-ID were always emitted as naive
    floating DATE-TIMEs; a floating EXDATE on a TZID event matches no instance, so
    excluded occurrences reappeared. Now parsed with the event's value type.
  • §4.3 _format_local_dt(): stripped the Z suffix — RFC 8984 LocalDateTime
    (override keys, until) must be suffix-less or strict servers won't match.
  • §4.4 STATUS was silently dropped both conversion directions
    (CANCELLED round-tripped as confirmed). Added the CONFIRMED/TENTATIVE/
    CANCELLED mappings.
  • §4.5 update_event sent the full object as the PatchObject, so removed
    properties (LOCATION, VALARM, …) silently persisted. Absent optional props are
    now set to null (RFC 8620 §3.3 semantics).
  • §4.6 search() passed datetimes through isoformat() instead of the
    ...Z UTCDate format JMAP requires.
  • §4.7 get_objects_by_sync_token() discarded newState, forcing a racey
    follow-up get_sync_token(). Now returns (added, modified, deleted, new_sync_token).

Dedup / perf

  • §5.1 Response-parsing logic moved into shared static methods on
    _JMAPClientBase; sync/async public methods are now thin wrappers
    (async_client.py 550 → 415 lines), so future fixes live in one place.
  • §5.5 One persistent HTTP session per client instead of per request.

Testing

tests/test_jmap_unit.py — 266 passed.

This is part of a series of commits done to fix code review issues
listed in docs/design/FULL_CODE_REVIEW_2026-06.md.  The fixes were
AI-generated; prompts were on the style "fix the code review issues".

§1.3 create_event() has guarded against this since the original implementation:
if 'new-0' not in created: raise JMAPMethodError(...).  create_task() was
missing the same check in both sync and async clients, so a JMAP server
returning an empty created dict (possible when the server silently ignores
the create) raised a bare KeyError instead of the documented JMAPMethodError.

§4.1 jscal_to_ical: override child VEVENT with no "start" patch key got
DTSTART from the master start_str instead of the occurrence's own time
(the override key).  Common title-only changes relocated every override to
the master's first occurrence, breaking override display entirely.  Default
is now the override_key itself.

§4.2 jscal_to_ical: EXDATE and RECURRENCE-ID values were always emitted as
naive floating DATE-TIMEs.  Per RFC 5545 the value type must match DTSTART.
A floating EXDATE on a TZID-anchored event does not match any instance, so
excluded occurrences reappeared.  Override keys are now parsed with the event
timezone applied (TZID events) or converted to date objects (all-day events).

§4.3 _utils.py _format_local_dt(): UTC datetimes produced a Z-suffixed string.
RFC 8984 §1.4 defines LocalDateTime (required for recurrenceOverrides keys and
recurrenceRules.until) as YYYY-MM-DDThh:mm:ss without any suffix.  Z-suffixed
override keys cannot match LocalDateTime occurrence keys on strict servers.
Function now always returns a timezone-stripped representation.

§4.4 ical_to_jscal and jscal_to_ical: STATUS was silently dropped in both
conversion directions.  STATUS:CANCELLED round-tripped as status:confirmed
(JSCalendar default), making cancelled meetings appear active.  Added mappings
CONFIRMED↔confirmed, TENTATIVE↔tentative, CANCELLED↔cancelled in both
directions.

§4.5 RFC 8620 §3.3: absent keys in a PatchObject preserve the server value; only
explicit null entries delete a property. update_event sent the full converted
JSCalendar object as the patch, so properties the caller removed (LOCATION,
VALARM, DESCRIPTION, etc.) were simply absent and silently persisted on the
server after the update.  After converting ical_str to a JSCalendar dict, set all optional
top-level properties to null when they are absent from the result. The list
is maintained in caldav/jmap/convert/_patch.py and applied identically in
both the sync (client.py) and async (async_client.py) update_event methods.

§4.6: JMAPCalendar.search() passed datetime args through isoformat(), producing
+HH:MM or bare datetimes instead of the UTCDate format (...Z) JMAP requires.
Added _to_utcdate() helper in calendar.py that converts to UTC and strips microseconds.

§4.7: get_objects_by_sync_token() discarded newState from CalendarEvent/changes
into _, forcing callers to do a separate get_sync_token() call (race window).
Now returns a 4-tuple (added, modified, deleted, new_sync_token). Updated all
callers in unit and integration tests.

§5.1: Moves all response-parsing logic from JMAPClient and AsyncJMAPClient into
static methods on _JMAPClientBase.  Each sync/async public method is now a
~3-line wrapper: get session, dispatch _request(), delegate to the shared
parser.  async_client.py drops from 550 → 415 lines; the parsers live in one place so
future fixes (like the §1.13 create_task KeyError and §4.5 update_event
nulling that this branch already carries) no longer need to be duplicated.

§5.5: Hold one persistent HTTP session per client instead of per request
@tobixen tobixen requested a review from SashankBhamidi June 19, 2026 09:41
Comment thread tests/test_jmap_unit.py
# To actually delete a property the patch must set it to null.
# An ical → jscal conversion that omits LOCATION/DESCRIPTION must send
# {"locations": null, "description": null, ...} so the server removes them.
_ICAL_WITH_LOCATION = (
# a CalendarEvent/set update when they are absent from the converted result.
# This ensures properties removed client-side (e.g. LOCATION deleted from
# the iCalendar) are actually removed on the server, not silently preserved.
_NULL_FOR_UPDATE: frozenset[str] = frozenset(
@SashankBhamidi

Copy link
Copy Markdown
Collaborator

Hi, is it okay if I review this on Sunday or later? I'm currently drowned in a lot of business work right now.

@tobixen

tobixen commented Jun 19, 2026

Copy link
Copy Markdown
Member Author

Sure. While I had hoped to release caldav 3.3.0 this week, realistically I will not manage.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants