MainSDK
Search⌘K
Interfaces

Data

Calendars

calendar

Store events with recurrence and time zones, import and export iCalendar, and show free/busy without showing details.

Building for 1.2ships as a experimental APIPhase 3 · Mobile and regulatedSpec calendarFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
Updated 2026-10-28

calendar stores events with recurrence and time zones, imports and exports iCalendar, and shows free/busy without details. Reach for it for bookings, team schedules or a mail client's calendar.

Events keep the wall-clock time a person chose in their zone, so "every Monday at 09:00 in New York" stays at 09:00 through daylight-saving changes. Recurring series can be edited one occurrence at a time or from a date onward.

At a glance

calendar:
  store: db
  max-occurrences: 5000
  max-window: 366d

Calls

  • calendar.createCalendar({ id?, name, owner? })
  • calendar.calendars()
  • calendar.create(cal, event)
  • calendar.get(id)
  • calendar.update(id, patch, { scope, occurrence?, version })
  • calendar.delete(id, { scope, occurrence? })
  • calendar.list({ calendars, from, to, cursor?, limit? })
  • calendar.freeBusy({ calendars?, members?, from, to })
  • calendar.import(cal, source)
  • calendar.export(cal, { from?, to? })
  • calendar.subscribe(cal)

Guarantees

Whatever the driver.

  • Wall-clock times stay put across daylight-saving changes
  • Recurrence expansion is bounded
  • Free/busy never shows titles, places or attendees to someone who can't read the calendar

Drivers

3 drivers. Select one to see its line in main.yaml.

main.yamlYour database (the tenant's region store) (default)
calendar:
  driver: db

Change this one line to swap providers; your calls stay the same.Your database integration

Codes

CodeWhere it appearsMeaning
MAIN-CAL-0001Checkmainsdk check, before deployAn event is invalid: end before start, an unknown IANA zone, a date-time mixed with a date, or no title (400)
MAIN-CAL-0002Checkmainsdk check, before deployA recurrence rule is invalid or uses an unsupported part (400)
MAIN-CAL-0003RuntimeError responses and logsA window passes max-window, or the expansion passes max-occurrences (400)
MAIN-CAL-0004RuntimeError responses and logsiCalendar text can't be parsed at a line; that event is skipped
MAIN-CAL-0005RuntimeError responses and logsAn import is larger than calendar.max-import (413)
MAIN-CAL-0006RuntimeError responses and logsNo calendar with that id in the tenant, or access denies it (404 or 403)
MAIN-CAL-0007RuntimeError responses and logsAn edit scope that doesn't fit the event or occurrence (400)
MAIN-CAL-0008RuntimeError responses and logsThe event changed since version was read (409)

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
calendar@1 contractPhase 31.2calendar#contract
Events in IANA time zonesPhase 31.2calendar#events
RFC 5545 recurrencePhase 31.2calendar#rrule
Edit one, following or all occurrencesPhase 31.2calendar#edit-scope
iCalendar import and exportPhase 31.2calendar#ical
Free/busyPhase 31.2calendar#freebusy

Quick start

main.yaml
calendar:
  store: db
  max-occurrences: 5000
  max-window: 366d
app/calendar.ts
import { Calendar } from "mainsdk/experimental/calendar";

type CalendarDeps = ConstructorParameters<typeof Calendar>[0];

declare const config: Record<string, unknown>;      // the calendar section of main.yaml
declare const stores: CalendarDeps["stores"];         // the region SQL stores your db uses
declare const tenant: CalendarDeps["tenant"];
declare const currentUser: { id: string };

const calendar = new Calendar({
  config,
  stores,
  tenant,
  subject: () => ({ id: currentUser.id }),
});

await calendar.createCalendar({ id: "team", name: "Team" });
await calendar.create("team", {
  title: "Stand-up", start: "2026-11-02T09:00", end: "2026-11-02T09:15",
  timeZone: "America/New_York", rrule: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
});

Configuration

KeyDefaultMeaning
storedbWhere events live: db or local. CalDAV, Google and Microsoft come later through connections
max-occurrences5000The most occurrences one list call expands
max-window366dThe widest window a list or free/busy call may ask for
max-import5MBThe largest iCalendar text an import accepts

What you get

Every call runs in the current tenant and checks access on the calendar.

CallWhat it does
calendar.createCalendar({ id?, name, owner? })Make a calendar; the owner is the caller unless one is named
calendar.calendars()List the calendars the caller may read
calendar.create(cal, event)Add an event. start and end are local date-times, or dates for an all-day event; timeZone is an IANA name, and without one the event floats. Takes rrule, exdates and rdates for recurrence
calendar.get(id)Read one event with its version
calendar.update(id, patch, { scope, occurrence?, version })Change an event. scope is this, following or all; a stale version fails with MAIN-CAL-0008
calendar.delete(id, { scope, occurrence? })Delete one occurrence, this and the following, or the whole series
calendar.list({ calendars, from, to, cursor?, limit? })Return expanded occurrences in a window, as UTC instants plus local times
calendar.freeBusy({ calendars?, members?, from, to })Return merged busy intervals, with tentative ones marked. It never returns titles, places or attendees to someone who cannot read the calendar
calendar.import(cal, source)Read iCalendar text or a file. Events with the same UID update instead of duplicating; a bad event is skipped and listed with its line
calendar.export(cal, { from?, to? })Write valid RFC 5545 text that imports back unchanged
calendar.subscribe(cal)Return a getqr.id share link to the calendar feed, never a raw URL

Always:

  • Wall-clock times stay put across daylight-saving changes. A time in the spring-forward gap moves forward by the gap, and a time that happens twice uses the first.
  • Recurrence expansion is bounded by max-window and max-occurrences.
  • Free/busy never shows titles, places or attendees to someone who can't read the calendar.
  • Events stay in the tenant's region and are labelled personal.

Examples

Move one Monday

ts
import type { Calendar } from "mainsdk/experimental/calendar";

declare const calendar: Calendar;
declare const eventId: string;

const week = await calendar.list({ calendars: ["team"], from: "2026-11-01", to: "2026-11-30" });
await calendar.update(eventId, { start: "2026-11-17T11:00", end: "2026-11-17T11:15" }, {
  scope: "this", occurrence: week.items[2].occurrence, version: 1,
});

Show when people are busy

ts
import type { Calendar } from "mainsdk/experimental/calendar";

declare const calendar: Calendar;

const { busy } = await calendar.freeBusy({ calendars: ["a", "b"], from: "2026-11-02T00:00", to: "2026-11-03T00:00" });

Errors and checks

CodeWhenFix
MAIN-CAL-0001An event is invalid: end before start, an unknown zone, mixed date and date-time, or no title (400)Use an IANA zone and a start before the end
MAIN-CAL-0002A recurrence rule is invalid or uses an unsupported part (400)Drop the named part, or use a supported one
MAIN-CAL-0003A window passes max-window, or expansion passes max-occurrences (400)Ask for a narrower window
MAIN-CAL-0004iCalendar text can't be parsed at a line; that event is skippedFix the line the result names
MAIN-CAL-0005An import is larger than max-import (413)Split the file or raise max-import
MAIN-CAL-0006No such calendar in the tenant, or access denies it (404 or 403)Use a calendar you may read
MAIN-CAL-0007An edit scope that doesn't fit the event (400)Use all on a single event
MAIN-CAL-0008The event changed since version was read (409)Read the event again and retry

mainsdk check has a calendar gate: MAIN-CAL-0001 and MAIN-CAL-0002 in seed data and main.yaml, each with its file and line. Each code's cause and fix is in check codes.

Swapping drivers

store: db keeps events in the tenant's region store; store: local keeps them in process for tests and mainsdk dev. The calls do not change. A CalDAV driver comes later, with a connection.

Testing

store: local runs offline on the fake clock, so recurrence and daylight-saving cases need no real time. See Test your app.

Security and privacy

  • Every call checks access on the calendar, and another tenant's calendar reads as missing.
  • privacy.export includes the calendars a person owns and the events they created or attend; privacy.erase deletes owned calendars and removes the person from other events' attendees.
  • Subscriptions are getqr.id links, so the feed address is never handed out raw.

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet