Sync changes
Keep a local copy of a calendar up to date with the changes since a saved cursor.
To mirror your users' calendars into Postgres, apply the changes syncEvents returns and save its cursor for the next run. The first sync, without a cursor, returns everything the provider syncs: for most providers that's the whole calendar, while Outlook covers a window.
After that you get back only what changed, though some providers have to reread the whole calendar to find it. Check how each provider syncs before you choose how often to run it.
import { SyncCursorExpiredError, syncEvents } from '@cal-sdk/core';
async function sync(calendarId: string) {
const stored = await db.syncCursors.get(calendarId);
try {
const { changes, nextCursor } = await syncEvents({ calendar, calendarId, cursor: stored });
await applyChanges(changes);
await db.syncCursors.set(calendarId, nextCursor);
} catch (error) {
if (!SyncCursorExpiredError.isInstance(error)) throw error;
// The provider no longer accepts the cursor. Start over.
await db.events.clear(calendarId);
await db.syncCursors.delete(calendarId);
await sync(calendarId);
}
}Apply changes in order
Apply changes in the order they come. The same event can show up more than once in a round (Outlook does this), and the later entry is the newer one, so applying them in order leaves you with the latest version. An upsert carries an event to insert, or to replace the one with the same id, and a delete carries the eventId to remove:
import type { SyncChange } from '@cal-sdk/core';
async function applyChanges(changes: SyncChange[]) {
for (const change of changes) {
if (change.type === 'upsert') await db.events.put(change.event);
else await db.events.delete(change.eventId);
}
}Sync returns what the provider stores, not computed occurrences: single events, series and exceptions. A daily standup with no end has infinitely many occurrences, so there's no sensible way to sync them all. To show a week, expand series yourself, or call listEvents for that window.
Cursors
Store the cursor as a string and never parse it: depending on the provider, it's a provider token or a list of fingerprints. Save it only after the changes are applied, so a crash in between replays them instead of losing them.
Providers can expire a cursor, for example after a long time unused, and then syncEvents throws SyncCursorExpiredError. Throw away the cursor and your copy, and sync again without one, as the example at the top does.
Large syncs
syncEvents follows every page and returns all changes at once. A first sync of years of history can be large, so take it one page at a time with paginate: false:
let pageToken: string | undefined;
let cursor: string | undefined;
do {
const page = await syncEvents({ calendar, calendarId, cursor: stored, paginate: false, pageToken });
await applyChanges(page.changes);
pageToken = page.nextPageToken;
cursor = page.nextCursor;
} while (pageToken);
await db.syncCursors.set(calendarId, cursor);Pass the same calendarId and cursor on every page of a round. The last page has no nextPageToken and carries nextCursor.
How each provider syncs
| Provider | How |
|---|---|
| Google sync tokens | |
| Outlook | Graph delta queries over a fixed window, by default a year back and two years ahead |
| CalDAV | The server's sync tokens (RFC 6578) |
| Cal.com | Bookings by the time they last changed |
| Calendly | Rereads every meeting and compares it with the last sync. The cursor grows with the number of events |
| Zoom | Rereads every meeting and compares it with the last sync. The cursor grows with the number of events |
| ICS | Downloads the feed unless the server answers 304 Not Modified, then compares it with the last sync. The cursor grows with the number of events |
| Mock | A change log in memory |
Outlook syncs a window because Graph's delta query needs a date range. Pass providerOptions.microsoft.timeMin and timeMax on the first sync to choose a different one; the window is kept in the cursor after that.
No provider pushes changes yet, so run sync on a schedule, or when the user opens your app.