Add aggregate retarget

Moving an aggregate to a different endpoint is the sharpest failure mode in the
design, so it is a command rather than something a config edit triggers: sync
already refuses on target drift and points here.

The move settles against the old target first. An event a user created in the
aggregate and that has not yet reached a sink exists only there, and would be
lost the moment we stop looking at that calendar. Only then is every mapped event
rebuilt on the new target — a rewrite rather than a copy, since the rendered
content differs between backends that can and cannot suppress scheduling.

Orphans on the old target are kept unless --purge-old, and a purge is bounded by
the derivation, so events the user keeps in that calendar themselves survive.

Also fixed prepare_vdirs, which created a directory per endpoint. That told
pimsync a local collection existed before its remote counterpart had been seen,
so it tried to create the counterpart: unsupported for a read-only feed, and it
would have invented calendars on a CalDAV server. Only the vdir root is created
now; collections are pimsync's to make from what it discovers.

One test premise was wrong rather than the code: a foreign event in an aggregate
that has a sink is a user-created event and gets adopted into that sink, so it
never reaches a purge as an outsider. Both behaviours are now covered.

Verified against the live Posteo calendar after the change: still a clean no-op.

106 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
randogoth 2026-09-10 13:04:41 +03:00
parent 9f14c1f651
commit dd984e150a
4 changed files with 495 additions and 14 deletions

View file

@ -41,7 +41,7 @@ Safety-critical behaviour:
update and delete under `sendUpdates=none`. The Google attendee path depends on
it. Fallback if it fails: the same demotion transform used for CalDAV.
- [x] `sync` refuses to run on aggregate target drift, before reconciliation
- [ ] `aggregate retarget` — flush unrouted creations against the old target, then
- [x] `aggregate retarget` — flush unrouted creations against the old target, then
re-materialise; keep old orphans by default
- [x] Mass-deletion guard (`max_delete_fraction`), overridable with `--force`, with an
absolute floor so deleting a couple of events is never refused
@ -57,8 +57,8 @@ Tests:
`VALARM` intact, `PARTSTAT: DECLINED` maps to `TRANSP: TRANSPARENT`, bulk deletion aborts
- [ ] Safety (integration): the same against a real Radicale instance with an SMTP sink,
proving no mail is emitted
- [ ] Retarget: drift makes `sync` exit non-zero having written nothing and losing no source
event (verified by hand end to end; still needs an automated test)
- [x] Retarget: drift makes `sync` exit non-zero having written nothing and losing no source
event; purge is bounded by the derivation; an unrouted creation reaches a sink first
## M2 — interface and packaging