feat(calendar): a client can ask what changed, and be told the truth

CalendarState was the string "fixed", and its docblock explained that this
was a confession rather than a design: events are written from the sync
engine, from extraction, from the web editor and from CalendarEvent/set,
and a log recording a quarter of the writes "is not a weaker version of
it, it is a lie with a number on it". So canCalculateChanges was false,
there was no CalendarEvent/changes, and every client re-ran its query.

calendar_change_log closes that. The rows are written by a Doctrine
listener rather than by a recorder the writers call, and that is the whole
point — CalendarEvent exposes public mutable properties, so fields move
wherever somebody assigns one and lets the flush carry it. An RSVP in
InviteResponder, a SEQUENCE bump in EventReconciler, the recurrence flags
in RecurrenceMaterialiser: none pass through CalendarEventWriter, so a
recorder wired into write() would have missed all three and every deletion
besides. Doctrine already knows what changed; asking it is the only way to
be complete without asking every future caller to remember.

  CalendarChangeLog     the log. Its own table rather than new
                        JmapObjectType cases on jmap_change_log, which
                        CalendarState suggested: that table is keyed by
                        account_id, and CalendarAccountResolver says a
                        user can delete their last account and keep a
                        calendar. CalDAV also counts per collection, and
                        there is nowhere to put the collection.
  the listener          onFlush collects, postFlush writes. An ignore
                        list, not an allow list, so a field added later
                        defaults to being logged — over-recording costs a
                        wasted conditional GET, under-recording is the
                        silent failure.
  CalendarChangeReader  tokens and deltas, protocol-neutral, in both
                        scopes: per user for JMAP, per calendar for the
                        CalDAV server this is the groundwork for.

Two bugs fell out while building it.

The first was mine and is why postFlush writes SQL rather than persisting
entities. Flushing from inside postFlush re-runs the whole unit of work,
and the second pass sees what the first left behind: EventDismisser
removes an event while an EventSourceLink still points at it, which is
legal once and throws "a new entity was found through the relationship"
twice. It broke every calendar deletion in the suite and five demo-flow
tests. A plain INSERT stays out of the UnitOfWork entirely, so this
listener cannot make somebody else's flush fail.

The second is subtler: a flush that throws never reaches postFlush, so the
queued rows survived to be written by the next flush that succeeded —
logging changes the database had rolled back. onFlush clears the queue as
it starts. A worker whose EntityManager is reset and carries on is exactly
where that would have bitten.

CalendarEvent/get, /query and /set now return real sequences. Calendar/get
still does not: the log records events, not the collections holding them,
so a rename or a recolour moves nothing. That is a smaller gap than it
sounds — a user has a handful of calendars, Calendar/get returns all of
them by default, and CalDAV discovers the collection set with a PROPFIND
that holds no token. CalendarState now says so instead of describing a
world that no longer exists.

Also marks config/reference.php optional in phpstan.dist.neon. It is
gitignored and generated, every sibling entry in that exclude list already
carries (?), and without it PHPStan refuses to start on a clean checkout.

No deployment change. One new table, created on boot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5 committed 2026-08-31 22:16:15 +02:00
1 parent 6212d26a97
commit 2790c13a82
17 files changed
+1449 -41

No files matched your search

+1 -1
View File
@@ -73,7 +73,7 @@ parameters:
# Generated: 54 migrations nobody edits by hand, and a ~1000-line
# dump of the framework config tree that exists to be read.
- migrations
- config/reference.php
- config/reference.php (?)
# Twig extensions lifted from upstream rather than written here.
# NOTE: only Vendor/ — the rest of src/Twig is application code
# (SidebarCounts and friends) and must stay analysed.