Push local changes up to Google, completing the Google leg

Mirrors are created with events.import, updated and deleted with
sendUpdates=none, so a write to an aggregate cannot mail anyone about a meeting
that was already invited from its source.

The import question the plan flagged is settled from Google's own API discovery
document rather than by guesswork: events.import accepts no sendUpdates parameter
at all, while insert, update and delete all do, and it is documented as adding "a
private copy of an existing event". A method with notification behaviour would
need that control. Confirmed live that attendees survive an import with their
response statuses intact.

Testing against the real API caught a defect that unit tests could not: the first
push dropped VALARM entirely and Google substituted the calendar's default
reminders. Alarms are never stripped by decision, so they now map to and from
Google's reminder overrides in both directions, with relative TRIGGER durations
converted to whole minutes. A trigger Google cannot express — absolute, or after
the start — is dropped rather than guessed at.

Known gaps recorded in TODO.md rather than papered over: a series' exceptions are
not pushed, since Google models those as separate events against an existing
series; and a failed Google pull still aborts the cycle, which is safe but stops
the CalDAV side too.

121 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
randogoth 2026-09-10 13:21:01 +03:00
parent edb16204fa
commit bc64f19211
6 changed files with 675 additions and 14 deletions

27
TODO.md
View file

@ -32,17 +32,17 @@ Core modules:
parser does not always match its documentation
- [x] `google/auth.rs` — OAuth loopback flow with PKCE, refresh, keyring-sourced secrets
- [x] `google/convert.rs` — JSON to iCalendar, including recurrence and timezones
- [~] `google/api.rs` — incremental pull with syncToken done; the push direction
(import / update / delete) is still outstanding, so a Google endpoint is
currently read-only
- [x] `google/api.rs` — incremental pull by syncToken, and push by import / update
/ delete with notification suppressed
- [x] Reintroduce `SchedulingSuppression` in `config.rs` (removed in M0 as dead code)
Safety-critical behaviour:
- [ ] **`events.import` gate — do this first.** Import an attendee-bearing event whose
guests are on a mail sink we control and confirm no mail is emitted; repeat for
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] **`events.import` gate** — settled from Google's own API discovery document:
`events.import` accepts no `sendUpdates` parameter at all, while `insert`,
`update` and `delete` all do, and it is documented as adding "a private copy of
an existing event". Confirmed live that attendees and alarms survive an import.
Creation goes through `import`; update and delete pass `sendUpdates=none`.
- [x] `sync` refuses to run on aggregate target drift, before reconciliation
- [x] `aggregate retarget` — flush unrouted creations against the old target, then
re-materialise; keep old orphans by default
@ -63,6 +63,19 @@ Tests:
- [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
### Known gaps carried out of M1
- [ ] A recurring series' *exceptions* are not pushed to Google. Google models them
as separate events against an already existing series, so they need
`events.instances` plus a patch per exception. Reported per sync rather than
dropped silently.
- [ ] `push` deleting an event remotely is exercised only when the reconciler removes
a mirror mid-cycle; it has no end-to-end test yet, because a pull legitimately
resurrects anything deleted from a vdir before the cycle runs.
- [ ] A failed Google pull aborts the whole cycle, including the CalDAV side. Safe —
reconciling against a stale snapshot could read as mass deletion — but it means
a lapsed token stops everything.
## M2 — interface and packaging
- [ ] axum configuration UI, bound to 127.0.0.1