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.

@tobixen tobixen requested a review from SashankBhamidi June 19, 2026 09:41
Comment thread tests/test_jmap_unit.py Fixed
Comment thread caldav/jmap/convert/_patch.py Dismissed
@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.

@SashankBhamidi SashankBhamidi left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for running the full codebase review and pulling me in on the JMAP sections.

Most of this is correct and the §5.1 refactor is genuinely the right move structurally. Having every public async method be a three-line wrapper over _request() and a shared static parser means future fixes no longer need to be made in two places, which is exactly how the §1.3 create_task bug got there in the first place. The §4.5 implementation is also more careful than the original finding required: the _unsupported_null_keys retry loop only retries when every property in the server rejection is null-cleanup we injected, terminates because each iteration strictly shrinks the patch, and surfaces genuine invalidProperties errors without retrying. I traced through it.

One finding needs fixing before merge. The §4.3 change to _format_local_dt is correct in what it does but leaves the callers in an inconsistent state for TZID events with RRULE UNTIL clauses. Details in the inline comment. The STATUS maps and the dead test variable are non-blocking.

On the two bot findings: _NULL_FOR_UPDATE is not unused. It is imported at client.py:46 and used at line 178. Dismiss that one. The _ICAL_WITH_LOCATION finding is correct, noted below.

Comment thread caldav/jmap/convert/_utils.py
Comment thread caldav/jmap/convert/jscal_to_ical.py
Comment on lines +389 to +398
status = master.get("STATUS")
if status:
_STATUS_ICAL_TO_JSCAL = {
"CONFIRMED": "confirmed",
"TENTATIVE": "tentative",
"CANCELLED": "cancelled",
}
jscal_status = _STATUS_ICAL_TO_JSCAL.get(str(status).upper())
if jscal_status:
jscal["status"] = jscal_status

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue: _STATUS_ICAL_TO_JSCAL is rebuilt on every call. Should be a module-level constant alongside _CLASS_MAP, _PARTSTAT_MAP, etc.

Suggested change
status = master.get("STATUS")
if status:
_STATUS_ICAL_TO_JSCAL = {
"CONFIRMED": "confirmed",
"TENTATIVE": "tentative",
"CANCELLED": "cancelled",
}
jscal_status = _STATUS_ICAL_TO_JSCAL.get(str(status).upper())
if jscal_status:
jscal["status"] = jscal_status
status = master.get("STATUS")
if status:
jscal_status = _STATUS_ICAL_TO_JSCAL.get(str(status).upper())
if jscal_status:
jscal["status"] = jscal_status

Comment thread tests/test_jmap_unit.py Outdated
Comment on lines +1668 to +1676
_ICAL_WITH_LOCATION = (
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"
"BEGIN:VEVENT\r\n"
"UID:loc-uid@example.com\r\n"
"DTSTART:20240615T090000Z\r\n"
"SUMMARY:Event with Location\r\n"
"LOCATION:Old Conference Room\r\n"
"END:VEVENT\r\nEND:VCALENDAR\r\n"
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_ICAL_WITH_LOCATION is assigned but never passed to anything. Only _ICAL_WITHOUT_LOCATION is used at line 1688. Looks like scaffolding from an earlier draft. Non-blocking but should be dropped.

Suggested change
_ICAL_WITH_LOCATION = (
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"
"BEGIN:VEVENT\r\n"
"UID:loc-uid@example.com\r\n"
"DTSTART:20240615T090000Z\r\n"
"SUMMARY:Event with Location\r\n"
"LOCATION:Old Conference Room\r\n"
"END:VEVENT\r\nEND:VCALENDAR\r\n"
)
_ICAL_WITHOUT_LOCATION = (
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"
"BEGIN:VEVENT\r\n"
"UID:loc-uid@example.com\r\n"
"DTSTART:20240615T090000Z\r\n"
"SUMMARY:Event without Location\r\n"
"END:VEVENT\r\nEND:VCALENDAR\r\n"
)

@SashankBhamidi

Copy link
Copy Markdown
Collaborator

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

Sorry for the delay! Back to you, good to merge after my comments have been addressed.

I'll be afk for a few days so feel free to merge.

@tobixen

tobixen commented Jun 22, 2026

Copy link
Copy Markdown
Member Author

Well, I'm also delayed, hoped to get v3.3.0 launched yesterday, but it seems like there will be several days until I have time looking more into the caldav library.

@tobixen tobixen force-pushed the jmap-code-review-fixes branch from 0204af4 to d23eafd Compare June 27, 2026 12:33
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.  Because LocalDateTime
is in the event's own time zone, the event TZID is now threaded into
_rrule_to_jscal / _exdate_to_overrides so a UTC UNTIL/EXDATE on a TZID event is
shifted to event-local wall-clock (a UTC+2 series was ending two hours early);
the jscal->ical direction converts the local until back to UTC, since RFC 5545
§3.3.10 requires a UTC UNTIL for a TZID DTSTART.  (PR review, @SashankBhamidi.)
Also hoisted the STATUS maps to module-level constants and dropped a dead test
variable per the same review.

§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

Co-authored-by: Claude Sonnet 4.6 and Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Sashank Bhamidi <hello@sashank.wiki>
@tobixen tobixen force-pushed the jmap-code-review-fixes branch from d23eafd to 43226c5 Compare June 27, 2026 12:59
@tobixen

tobixen commented Jun 27, 2026

Copy link
Copy Markdown
Member Author

Thanks @SashankBhamidi — I had Claude go through this, I've looked through the changes and it seems like all the points you've raised have been resolved - do you agree? I've also verified that the new tests fails on 0204af4 but passes with 43226c5. I'm also asking it to come up with some integration tests.

Below is the AI-generated description of the changes done.


⚠️ This comment is AI-generated (Claude Opus 4.8 via Claude Code) on behalf of tobixen

§4.3 (blocking) — fixed. Threaded the event TZID into _rrule_to_jscal and _exdate_to_overrides, so a UTC UNTIL/EXDATE on a TZID event is now shifted to event-local wall-clock before formatting (your Berlin example now yields 2024-07-01T14:00:00). I also fixed the jscal→ical direction: _jscal_rrule_to_rrule now converts a LocalDateTime until back to UTC, so the round-trip emits UNTIL=…120000Z with the Z suffix that RFC 5545 §3.3.10 requires for a TZID DTSTART (it previously emitted a forbidden suffix-less value). Added two regression tests covering the UTC+2 UNTIL and EXDATE round-trips.

Nits — done. Hoisted _STATUS_ICAL_TO_JSCAL and _STATUS_JSCAL_TO_ICAL to module-level constants alongside the others; dropped the dead _ICAL_WITH_LOCATION test variable.

Bot _NULL_FOR_UPDATE — dismissed as a false positive (it's imported and used in update_event), as you noted.

I left the RECURRENCE-ID path at ical_to_jscal.py out of scope here since it's governed by the §4.2 override-key handling and isn't a UTC-on-TZID case; happy to revisit if you think it needs the same treatment.

⚠️ This comment is AI-generated (Claude Opus 4.8 via Claude Code) on behalf of tobixen

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