Sync Engine
Two-way external calendar sync is Musubi’s most distinctive subsystem. It lives
in apps/api/src/sync/ and is provider-agnostic: the engine does not talk to
a provider directly — it routes through a small adapter contract. Google,
Microsoft Graph, and CalDAV are the current implementations.
Mental model
Section titled “Mental model”flowchart LR
subgraph P[External providers]
G[Google]
M[Microsoft]
C[CalDAV]
end
subgraph EN[engine.ts]
SP["syncProvider(adapter, user, account)<br/>1 · listCalendars → reconcile mirrors<br/>2 · fetchChanges(cursor) → upsert<br/>3 · push local edits (pushCreate/Update/Delete)"]
end
subgraph DB[Local DB]
T["external_* · events · calendar_*"]
end
P -->|"pull · fetchChanges"| EN
EN -->|"push · pushCreate/Update/Delete"| P
EN --> DB
Each connected provider account produces mirror calendars — ordinary Musubi calendars with an externalCalendars mapping. Their events flow into the unified events table like any other event (that’s the point of “events beyond calendars”). Sync is bidirectional:
- Pull (provider → local):
fetchChangesreturns normalized events; the engine upserts them. - Push (local → provider): editing an event in a mirror calendar calls the adapter’s
pushCreate/Update/Delete.
The adapter contract
Section titled “The adapter contract”sync/adapter.ts defines three data shapes and the interface every provider implements.
type NormalizedEvent = { externalId: string; status: "active" | "cancelled"; // cancelled ⇒ delete locally title: string; start: Date; end: Date; // UTC; all-day end is exclusive isAllDay: boolean; description: string | null; location: string | null; organizer: string | null; recurrence: string | null; // iCal RRULE text (+ EXDATE lines) url: string | null; etag?: string | null; // CalDAV conflict detection};
type ExternalCalendarInfo = { externalId; name; color; readOnly?: boolean };type FetchChangesResult = { changes: NormalizedEvent[]; nextCursor: string | null; reset?: boolean };
interface CalendarAdapter { listAccounts(userID): Promise<{ id; label }[]>; listCalendars(userID, accountId): Promise<ExternalCalendarInfo[]>; fetchChanges(userID, accountId, externalCalendarId, cursor): Promise<FetchChangesResult>; pushCreate(userID, accountId, externalCalendarId, event): Promise<{ externalEventId }>; pushUpdate(userID, accountId, externalCalendarId, externalEventId, event): Promise<void>; pushDelete(userID, accountId, externalCalendarId, externalEventId): Promise<void>;
// calendar-level writes — create / rename+recolor / delete the calendar itself createCalendar(userID, accountId, { name, color }): Promise<{ externalId }>; updateCalendar(userID, accountId, externalCalendarId, { name, color }): Promise<void>; deleteCalendar(userID, accountId, externalCalendarId): Promise<void>;}NormalizedEvent is the currency of the whole system — every adapter translates its provider’s format to and from it.
Calendar-level writes
Section titled “Calendar-level writes”Mirrors are editable and deletable from Musubi, and POST /calendars can create
a calendar into a connected account. The calendar handlers apply these
provider-first: the adapter call runs before the local write, and a provider
rejection (Google refusing to delete a primary calendar, an expired token…)
prevents the local mutation. A later local database failure can still leave a
provider-only change, so this is ordering, not a distributed transaction.
Only the user whose account backs the mirror may perform these operations
(external_calendars.userID check); read-only mirrors map to viewer, so
assertCan blocks writes. Provider quirks stay in adapters: Google color lives
on the per-user calendarList entry, Microsoft maps hex to its nearest preset,
and CalDAV uses raw MKCALENDAR / PROPPATCH / DELETE.
Write path and failure semantics
Section titled “Write path and failure semantics”Event writes use a different contract. Every dependent local write is transactional: create commits the event, creator-attendee, and links together; update commits the event, link diff, and removed mappings together; delete commits unlinking and the possible final tombstone together.
Provider calls remain outside PostgreSQL. pushEventToCalendars() counts and
logs provider exceptions but intentionally catches them, so the local
transaction still commits when a provider copy cannot be changed. Deletes for
removed links run before the local transaction only because the external
mapping is needed to address the provider copy; creates and updates run after
commit. No durable retry outbox currently exists.
This distinction matters during incidents:
| Mutation | Ordering | Current failure result |
|---|---|---|
| Calendar create/update/delete | Provider, then local DB | Provider rejection aborts local change; later DB failure can diverge |
| Event create | Local DB transaction, then provider(s) | Provider failure is logged; request continues |
| Event update/delete | Provider delete for removed links, local DB transaction, then provider create/update | Provider failure is logged; local reconciliation continues |
| Scheduled pull | Provider fetch, then local reconciliation | Account/provider failures are isolated and retried next cycle |
Disconnecting one provider account uses removeExternalAccountData() to remove
all of its local mirrors, events, opt-out tombstones, and (for CalDAV) stored
credentials in one transaction. Google revocation and Better Auth OAuth unlink
still sit outside PostgreSQL and therefore keep the same cross-system boundary.
See the observability runbook and audit recommendation.
The engine
Section titled “The engine”sync/engine.ts orchestrates one account at a time in syncProvider():
-
Reconcile calendars.
listCalendars()→ delete local mirrors gone on the provider (removeCalendarcascades orphans), import new ones (importExternalCalendar), and refresh the read-only flag.readOnly: truemaps tocalendar_members.role = "viewer". -
Pull events. For each mirror,
fetchChanges(cursor). Ifreset: true(e.g. Google 410 cursor-expired),clearCalendarEvents()soft-deletes all first, then upserts fresh. Per event:status: "cancelled"→deleteExternalEvent; elseupsertExternalEvent(transactional — creates or revives the event row +calendar_events+external_eventsmapping). PersistnextCursor.
syncUser(userID) discovers accounts per provider, then wraps every account sync
independently. One expired Google account therefore neither aborts a second Google
account nor CalDAV. Account failures include the concrete accountId in structured
logs; provider-level discovery failures use sync.provider.failed.
The CalDAV connect handler uses the same path in an account-scoped strict mode
(syncUser(userID, { provider: "caldav", accountId, throwOnError: true })). This
makes the first calendar/event import part of a successful connection and returns
an import failure to the client. Scheduled syncs keep the default best-effort
behaviour.
Near-realtime: the scheduled sync
Section titled “Near-realtime: the scheduled sync”Provider changes reach the app without a manual pull-to-refresh through a three-piece loop:
- Scheduler (
apps/api/src/index.ts): everyEXTERNAL_SYNC_INTERVAL_MINminutes (default 5,0disables), the server runssyncUserfor every user who has at least one provider mirror (getExternalSyncUserIDs). - Change detection (the part that makes this sane):
upsertExternalEventreturns whether it actually wrote — with an etag (CalDAV) an unchanged, alive event is a verified no-op: no write, noupdatedAtbump, so neither the broadcast nor the client delta fire for nothing. Deletions are detected by a sweep: on a full-set fetch (reset: true— CalDAV always, Google after a 410), events whose external id wasn’t in the fetched set get tombstoned. The sweep replaced the old tombstone-everything-then-revive reset, which churned every event on every sync. - Broadcast → silent refresh: when a calendar really changed,
syncUsersends an SSEexternal_syncframe to its members; the client (useEventsStream) responds with a silent delta refresh —refresh({ providerSync: false }), skipping the provider-sync trigger so it can’t loop.
Two correctness details worth knowing: provider deletions tombstone (deletedAt) rather than hard-delete, so offline clients learn about them through the delta’s deletedIds (a hard delete just vanished — stale caches kept the event forever); and a client-originated push echoes back on the next poll as a write (values identical), which is harmless.
Account disconnect semantics
Section titled “Account disconnect semantics”The current client disconnects exactly one { provider, accountId } through
POST /api/v1/users/connections/disconnect. The handler:
- best-effort revokes that Google account’s refresh token (Microsoft has no equivalent step here);
- removes only mirror calendars and disabled-calendar markers for that account;
- asks Better Auth to unlink that exact account; and
- if Better Auth refuses—most commonly because it is the user’s final login
account—clears calendar tokens/scope only on the matching
(user, provider, accountId)row.
This preserves sibling Google/Microsoft accounts. The legacy
POST /api/v1/users/connections/google/revoke route has no accountId and is
intentionally provider-wide for old clients; do not use or describe it as an
exact-account operation.
Credential crypto
Section titled “Credential crypto”sync/crypto.ts encrypts CalDAV passwords with AES-256-GCM before they hit caldav_accounts.encryptedPassword:
encryptSecret(plaintext) → "base64(iv):base64(tag):base64(ciphertext)" (fresh 12-byte IV each time)decryptSecret(blob) → verifies the auth tag, throws on tamperingThe key is CALDAV_ENC_KEY (64 hex chars = 32 bytes; generate with openssl rand -hex 32). Plaintext is decrypted only when building a CalDAV client, never stored or logged. Without the key set, CalDAV connection fails at use time (not boot).
Provider specifics (the gotcha zones)
Section titled “Provider specifics (the gotcha zones)”Most sync bugs are recurrence and all-day edge cases. The existing adapters already handle these — study them before writing a new one.
Google (adapters/google.ts):
- The adapter refreshes expired access tokens against Google’s token endpoint so
it can retain the safe OAuth
error/error_subtypecodes that Better Auth’s genericgetAccessTokenerror otherwise discards. Tokens and error descriptions are never logged. invalid_grantis permanent until the user authorizes again. The account row is markedsync_status = reconnect_required, its dead tokens are cleared, andlistAccounts()excludes it from later scheduled runs. Existing mirrors remain visible with a reconnect action. A successful Better Auth OAuth relink resets the status toactivethrough the account update hook.- Network failures, rate limits, 5xx responses, and server OAuth configuration errors remain retryable and do not disable an individual user’s account.
sanitizeRecurrence()fixes three iCal quirks:EXDATE;TZID=…(the rrule lib ignores TZID → convert to UTC), all-dayEXDATEmust beVALUE=DATE, andFREQ=YEARLY;BYMONTHDAYwithoutBYMONTH(RFC expands monthly → anchor the month).- Incremental sync via
syncToken;410 Gone→ returnreset: true. - All-day end is exclusive (±1 day on pull/push).
Microsoft (adapters/microsoft.ts):
- OAuth mirrors the Google lifecycle exactly — both adapters share
sync/oauth.ts(token refresh,invalid_grant→reconnect_required) and the genericqueries/oauth.tsaccount queries. One Microsoft quirk handled there: the refresh token rotates on every refresh and must be re-persisted. - Incremental sync via Graph calendarView delta. v1.0 has no unbounded event
delta, so the delta token is bound to a fixed date window (
WINDOW_*constants, currently −180d…+730d). The cursor stores{link, windowEnd}; when the future edge gets within 90 days, the adapter forcesreset: truewith a fresh window. Events outside the window don’t exist in the mirror — a rolling view, not full history. - Recurring series arrive pre-expanded: calendarView returns occurrences and
exceptions as individual events, so
recurrenceis alwaysnullon pull — no RRULE conversion, and Outlook-side exceptions come for free. The flip side: pushing a Musubi-created recurring event into an Outlook mirror is rejected with a clear error (Graph models recurrence as structured patterns, not RRULE/EXDATE; converting is the upgrade path). Prefer: outlook.timezone="UTC", outlook.body-content-type="text"on every read — times come back UTC and bodies as plain text.- Graph only accepts preset color names (
hexColoris read-only), so create and update map the chosen Musubi hex to the nearest Graph preset.
CalDAV (adapters/caldav.ts + caldav_client.ts):
- Wraps
tsdav; Basic auth; principal/home discovery is automatic (incl. iCloud hosts). - Events parsed via
ical.js;externalIdis the resource URL, not the UID. - Always returns
reset: true— full fetch every sync (a deliberate simplification; upgrade path is WebDAVsync-collection). This cleanly handles deletions. - Passwords are trimmed (mobile clipboards add whitespace) then decrypted per request.
How to add a provider
Section titled “How to add a provider”-
Create
adapters/<provider>.tsimplementingCalendarAdapter. Translate the provider’s API to/fromNormalizedEvent. -
Credentials. OAuth: add the provider to Better Auth (
packages/auth) and implementlistAccounts()against theaccounttable. Preserve the provider’s machine-readable refresh error code so permanent revocation can be distinguished from a transient outage. Password-based: model it oncaldav_accountsand encrypt withencryptSecret(). -
fetchChanges. Paginate internally, accumulate all pages into onechangesarray, return the provider’s opaquenextCursor. Map cursor-expiry errors toreset: true. -
push*idempotency. Deletes must treat 404/410 as success. -
Register in the engine — add
<provider>: <adapter>to the registry map inengine.ts. TheexternalCalendars.provider/externalEvents.providercolumns are free-form strings, so no schema change is needed. -
Wire connect/disconnect handlers (model on
handlers/caldav.ts/handlers/google.ts) and trigger an initialsyncUser().
Testing provider boundaries
Section titled “Testing provider boundaries”pnpm test includes two provider-neutral safety nets:
sync/orchestrator.test.tsproves discovery/account failures are isolated, retryable accounts run again on the next cycle, strict connect flows throw, and provider/account filters do not leak work; andsync/adapters/provider_http.test.tsruns local fake Google/Graph servers to exercise multi-page deltas,410cursor reset, partial-result discard, Microsoft series-master cache reset/reuse and safe caller retry.
For stored credential behavior, run pnpm test:provider-db against a disposable
migrated PostgreSQL database. Its fake token endpoint verifies encrypted access
and refresh-token rotation, account-scoped invalid_grant, sibling preservation
and transient failure retry. See Commands
for the required test-only environment.
These checks intentionally do not impersonate a live provider. Before a release that changes an adapter, still perform one sandbox round trip with real credentials: connect, initial pull, incremental pull, create/update/delete, token refresh and disconnect.
Adapter checklist
Section titled “Adapter checklist”- Cursor expiry →
reset: trueso the engine wipes and refetches - Read-only calendars (holidays, subscriptions) →
readOnly: true - All-day end is exclusive — adjust ±1 day on pull/push
- Recurrence: normalise provider format to iCal RRULE; EXDATE value-type must match DTSTART
- EXDATE with a TZID → convert to UTC (rrule lib ignores TZID)
- Push includes full RRULE + EXDATE on every update
-
organizermay be null → engine defaults to"" - Multiple accounts per provider supported via
listAccounts() - Deletes idempotent (404/410 = success)
- Calendar-level ops (
createCalendar/updateCalendar/deleteCalendar) throw on provider rejection — the handlers rely on that to abort the local write
Reference
Section titled “Reference”For setup and provider-specific debugging, use the dedicated guides: Google, Microsoft / Outlook, and Apple / CalDAV.
| File | Contents |
|---|---|
sync/engine.ts |
syncProvider, syncUser, pushEventToCalendars, registry |
sync/orchestrator.ts |
Provider/account failure boundary, strict/scoped selection |
sync/adapter.ts |
NormalizedEvent, ExternalCalendarInfo, FetchChangesResult, CalendarAdapter |
sync/adapters/google.ts |
Google Calendar adapter |
sync/adapters/microsoft.ts |
Microsoft Outlook adapter (Graph calendarView delta) |
sync/adapters/caldav.ts + caldav_client.ts |
CalDAV adapter (Apple/iCloud + generic) |
sync/oauth.ts |
shared OAuth access-token refresh (google + microsoft) |
sync/crypto.ts |
AES-256-GCM encryptSecret/decryptSecret |
packages/db/src/queries/external.ts |
upsertExternalEvent, clearCalendarEvents, removeExternalAccountData, cursor & link helpers |