Add the events API

One path with an EditScope on the write verbs. v1 had a second parallel
tree at /api/calendar/events/series/* -- 1,165 lines mostly duplicating
the non-series handlers, dispatching on string literals in 53 places where
a typo was a runtime fallthrough.

What a scoped edit means to the stored .ics lives in runway-core::series,
pure and tested without a server, because that is the subtle part and v1
shipped it with no coverage at all. Editing one occurrence writes an
override and no EXDATE: an EXDATE says the occurrence does not happen, an
override says it happens differently, and writing both is contradictory.
Deleting one writes the EXDATE and removes any override that named it.

Splitting a series divides its bound rather than dropping it. Six weekly
occurrences split at the third become two plus four, not two plus
forever -- the count is what the person asked for and it should survive
being cut. Overrides after the split move to the new series; moving a
whole series shifts its overrides' RECURRENCE-IDs by the same amount
instead of leaving them pointing at occurrences that no longer exist.

Every write states a precondition. There is no unconditional path: an
update without an ETag is refused, and a stale one is a conflict rather
than a silent overwrite. UIDs are minted server-side, because a
client-supplied one could collide with and replace an unrelated event.

Reads use time-range and fan out across calendars concurrently. Zones that
cannot be resolved are reported in the response instead of being rendered
as though they were fine.

Two tests found real bugs: sub-second timestamps cannot survive
iCalendar's one-second resolution, and splitting at the first occurrence
was dropping the recurrence rule and quietly turning a series into a
single event.
This commit is contained in:
2026-08-26 17:07:38 -04:00
parent c7e22f4431
commit c4e8ede28c
14 changed files with 2336 additions and 4 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Backend
axum = "0.8"
axum-extra = { version = "0.10", features = ["cookie"] }
axum-extra = { version = "0.10", features = ["cookie", "query"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace"] }