Add the calendar data layer: JSCalendar events with materialised occurrences

Storage is JSCalendar (RFC 8984) in jsonb, with everything a query touches
projected into real columns. Both halves earn their place. JSCalendar is
canonical because everything this will ever talk to speaks it or converts
cleanly — iCalendar both ways, CalDAV, JMAP calendars when that draft
lands — and a bespoke schema gets re-derived badly the first time an .ics
round-trips, silently dropping participants, alerts and overrides. But
Postgres cannot do range logic on "PT1H", so the columns exist too.

Recurrence is the part that is easy to get almost right, and there are
only three options. Expanding RRULEs at query time means loading every
recurring event ever created to answer "what is in July" — a standup from
2019 still generates instances — and nothing is indexable. Materialising
everything does not terminate for FREQ=DAILY with no UNTIL. So:
materialise to a bounded horizon, roll it forward nightly, and fall back
to live expansion outside it.

calendar_event_occurrence carries a GENERATED tsrange, and views read it
with `span && tsrange(from, to)` against a GiST index. The naive
`starts_at < :to AND ends_at > :from` is not just slower — it silently
misses the conference that started last week and is still running, because
starts_at ordering stops approximating ends_at ordering once multi-day
events exist. That index lives only in the migration: this DBAL has no
flag for USING gist, so every schema diff asks to drop it, and nothing
here may run doctrine:schema:update.

Two findings from writing the tests:

  * sabre accepts FREQ=SECONDLY and FREQ=MINUTELY at validation but its
    iterator has no branch to advance on either, so it yields one instant
    forever. The occurrence cap turned that into a thousand identical rows
    — a worse failure than a hang, because it looks like it worked. The
    converter now refuses both frequencies, and the materialiser stops on
    any rule that fails to move forward.
  * Expansion has to happen in the event's own zone. A 09:00 Berlin
    standup is 08:00 UTC in winter and 07:00 UTC in summer; expanding in
    UTC passes every test written in UTC and moves everyone's meeting
    twice a year. Pinned by testWeeklyExpansionHoldsLocalTimeAcrossADst-
    Transition.

Provenance tables (event_source_link, event_suppression) ship now though
nothing writes them yet: they are what will let extraction re-run over a
whole mailbox as a backfill rather than a resync, and a suppression the
user has already expressed must survive that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5 committed 2026-08-01 00:33:59 +02:00
1 parent 379f6771dd
commit d3fb1692b3
25 files changed
+2880 -1

No files matched your search

+1
View File
@@ -20,6 +20,7 @@
"minishlink/web-push": "^11.0",
"phpdocumentor/reflection-docblock": "^6.0",
"phpstan/phpdoc-parser": "^2.3.3",
"sabre/vobject": "^4.6",
"scheb/2fa-backup-code": "^8.6",
"scheb/2fa-bundle": "^8.6",
"scheb/2fa-totp": "^8.6",