Skip to content

feat(sync): execute provider update commands with safe replay#2255

Merged
tyler-dane merged 2 commits into
mainfrom
feat/sync-provider-mutations
Jul 21, 2026
Merged

feat(sync): execute provider update commands with safe replay#2255
tyler-dane merged 2 commits into
mainfrom
feat/sync-provider-mutations

Conversation

@tyler-dane

Copy link
Copy Markdown
Contributor

What

The provider-update half of S28: routes an update of a single, provider-linked event to the owning provider (Google) when provider work is active, with replay-safe conflict handling — the "one safe replay" the ledger calls for. Cloud updates (S28.1), and series/move/delete, are unchanged/deferred.

The replay-safety problem

Unlike provider create (idempotent for free via a deterministic id + Google's 409 read-back), a successful patchEvent changes the version. So a crash-before-confirm retry would re-send the now-stale expected version → Google returns 412 → a naive executor reports versionConflict for an edit that actually landed. That's a false failure on a successful write.

The design: fetch-first

executeProviderUpdate:

  1. Resolve the access token (transient refresh → pending; revoked/missing → failed, no provider call).
  2. fetchEvent the provider's current state.
    • Gone / cancelled → failed{permanentProviderError} (nothing to update).
    • Already carries this command's intended content → the edit landed on a prior attempt → confirm at the current version, no second write (the safe replay).
  3. Otherwise patchEvent conditionally (If-Match = the command's expected version). A genuine concurrent external edit → 412 → failed{versionConflict}; transient → pending.
  4. Commit the new content + version to the canonical record via owner-scoped, non-upsert replaceExisting (no resurrection of a concurrently-deleted event), then confirm.

The content comparison only gates the replay shortcut, so a false miss fails safe: it falls through to the conditional patch, whose If-Match yields a spurious conflict at worst — never a silently-lost external edit.

Invitation

Per the decision, the update command carries the client-driven invitation (default none), passed straight to patchEvent.

Tests

  • Executor (6, fake writer): patch-and-commit happy path (asserts If-Match + invitation pass-through), replay (fetched content matches intent → confirm, zero patch calls), genuine conflictfailed{versionConflict}, provider-event-gone → failed{permanentProviderError}, transient patch → pending, revoked credential → failed with no provider call.
  • Dispatch (1): a provider-linked update under active mode routes to the executor and patches.
  • Core: update defaults invitation to none; accepts an explicit one.

All core (490) + sync (364) suites green.

🤖 Generated with Claude Code

tyler-dane and others added 2 commits July 21, 2026 16:56
Route an update of a single, provider-linked event to the owning provider when
provider work is active. The hard part is replay safety: a successful
conditional patch changes the provider version, so a naive crash-then-retry
would re-send the now-stale expected version and the provider would reject it
as a conflict — misreporting an edit that actually landed.

So the executor fetches the provider's current state first. If it already
carries the command's intended content, the edit landed on a prior attempt and
the command is confirmed at the current version with no second write. Otherwise
it patches conditionally (If-Match on the expected version), which turns a
genuine concurrent external edit into a typed versionConflict failure. The
content check only gates the replay shortcut, so a false miss falls through to
the conditional patch — a spurious conflict at worst, never a lost external
edit. The new content and version are committed to the canonical record
(owner-scoped, non-upsert) before the command is confirmed.

Adds the client-driven invitation to the update command (default none), passed
through to the patch. Series events, single-to-series conversions, and
provider move/delete remain deferred to later slices.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
matchesIntendedEdit deep-compared all of the event content, but the provider
adapter only writes title/description/location/schedule — organizer, attendees,
and conference are read-reflected and drift independently (an attendee RSVPs,
etc.). So after a patch landed, a routine attendee response made the fetched
content differ from the client's snapshot, the replay check missed, and the
retry fell through to a patch conditioned on the now-stale version — turning an
edit that already succeeded into a spurious versionConflict failure.

Compare only the fields the patch actually writes. Add a regression test where
the written fields match but an attendee has RSVP'd, asserting the replay is
still recognized with no second write.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tyler-dane
tyler-dane merged commit 0285e3c into main Jul 21, 2026
18 checks passed
@tyler-dane
tyler-dane deleted the feat/sync-provider-mutations branch July 21, 2026 23:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant