npm package: @svutil/cally
Description: Calendar integrations with automatic token management
Latest version: 0.0.5

# cally

**Calendar sync that just works.**

OAuth tokens, automatic refresh, delta sync, webhooks—handled. You bring the storage, cally handles the rest.

```typescript
import { Calendar } from "@svutil/cally";

const cal = Calendar.microsoft({
  clientId: process.env.MS_CLIENT_ID,
  clientSecret: process.env.MS_CLIENT_SECRET,
  adapter: tokenAdapter,
});

await cal.auth.setTokens(userId, tokens);
const events = await cal.events.list(userId);
```

## The Problem

Calendar integrations are deceptively annoying:

- **Token refresh** - Access tokens expire. Refresh tokens die. You need to handle both invisibly.
- **Sync state** - Microsoft's delta API is efficient but stateful. You need to track delta tokens per-user, per-calendar.
- **Webhooks** - Subscriptions expire after 3 days. Miss a renewal and you're polling blind.
- **Error handling** - Rate limits, permission changes, token revocation—each needs different handling.

Most teams end up building this infrastructure from scratch. It's not hard, but it's tedious and easy to get wrong.

## What cally does

cally is a thin wrapper around Microsoft Graph (Google coming) that handles the boring parts:

- **Invisible token refresh** - Every API call checks expiry, refreshes if needed
- **Delta sync** - Full sync creates a checkpoint, incremental sync uses it
- **Webhook lifecycle** - Create, renew, parse notifications, handle edge cases
- **Typed errors** - `REAUTH_REQUIRED` means prompt the user, `RATE_LIMITED` means back off

You provide adapters for storage (tokens, sync state, subscriptions). We provide Prisma and Supabase adapters, or you write your own.

## Installation

```bash
npm install @svutil/cally
```

## Quick Start

```typescript
import { Calendar } from "@svutil/cally";
import { PrismaTokenAdapter, PrismaSyncAdapter } from "@svutil/cally/prisma";

const cal = Calendar.microsoft({
  clientId: process.env.MS_CLIENT_ID,
  clientSecret: process.env.MS_CLIENT_SECRET,
  adapter: new PrismaTokenAdapter(prisma),
  syncAdapter: new PrismaSyncAdapter(prisma),
});

// After OAuth callback
await cal.auth.setTokens(userId, {
  accessToken: tokens.access_token,
  refreshToken: tokens.refresh_token,
  expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
});

// Initial sync - fetches everything, creates delta token
const result = await cal.sync.fullSync(userId);

// Later syncs - only gets changes
const changes = await cal.sync.incrementalSync(userId);
```

## Real-time Updates

For apps that need instant updates (meeting notes, scheduling tools):

```typescript
// Create webhook subscription
await cal.subscriptions.ensure(userId, {
  webhookUrl: "https://yourapp.com/webhooks/calendar",
});

// Handle incoming webhooks
app.post("/webhooks/calendar", async (req, res) => {
  if (req.query.validationToken) {
    return res.send(req.query.validationToken);
  }

  await cal.sync.incrementalSync(userId);
  // Push to connected clients via websocket/SSE

  res.status(202).send();
});

// Cron job - renew before expiry (subscriptions last ~3 days)
await cal.subscriptions.renewAll();
```

## API

### Events

```typescript
cal.events.list(userId, { startDate, endDate })
cal.events.get(userId, eventId)
cal.events.create(userId, { title, start, end, ... })
cal.events.update(userId, eventId, { title })
cal.events.delete(userId, eventId)
cal.events.listCalendars(userId)
```

### Sync

```typescript
cal.sync.fullSync(userId); // Initial sync, creates delta token
cal.sync.incrementalSync(userId); // Uses delta token, only gets changes
```

### Subscriptions

```typescript
cal.subscriptions.ensure(userId, { webhookUrl }); // Create or renew
cal.subscriptions.renewAll(); // For cron jobs
cal.subscriptions.delete(userId);
```

### Health

```typescript
const health = await cal.health.check(userId);
// { isConnected, tokenExpiresAt, hasSubscription, lastSyncAt }
```

## Error Handling

```typescript
import { CalendarError } from "@svutil/cally";

try {
  await cal.events.list(userId);
} catch (error) {
  if (error instanceof CalendarError) {
    if (error.code === "REAUTH_REQUIRED") {
      // Refresh token is dead, user needs to reconnect
    }
    if (error.code === "RATE_LIMITED") {
      // Back off, retry after error.retryAfterMs
    }
  }
}
```

## Adapters

### Prisma

```typescript
import {
  PrismaTokenAdapter,
  PrismaSyncAdapter,
  PrismaSubscriptionAdapter,
  PRISMA_SCHEMA,
} from "@svutil/cally/prisma";

// Add PRISMA_SCHEMA to your schema.prisma
```

### Supabase

```typescript
import {
  SupabaseTokenAdapter,
  SupabaseSyncAdapter,
  SupabaseSubscriptionAdapter,
  SUPABASE_SCHEMA,
} from "@svutil/cally/supabase";

// Run SUPABASE_SCHEMA as migration
```

### Custom

```typescript
interface TokenAdapter {
  getTokens(userId: string): Promise<Tokens | null>;
  saveTokens(userId: string, tokens: Tokens): Promise<void>;
  deleteTokens(userId: string): Promise<void>;
}
```

## Production Checklist

For reliable calendar sync in production:

1. **Auto-sync on connect** - Run `fullSync` right after OAuth, not manually
2. **Webhook renewal cron** - Run `subscriptions.renewAll()` every 12 hours
3. **Fallback sync cron** - Run `incrementalSync` every 15 minutes as safety net
4. **Handle REAUTH_REQUIRED** - Prompt users to reconnect when refresh tokens die

```typescript
// Webhook handler
app.post("/webhooks/calendar", async (req, res) => {
  if (req.query.validationToken) return res.send(req.query.validationToken);
  await cal.sync.incrementalSync(userId);
  res.status(202).send();
});

// Cron: every 12 hours
await cal.subscriptions.renewAll();

// Cron: every 15 minutes (belt and suspenders)
for (const userId of activeUsers) {
  await cal.sync.incrementalSync(userId);
}
```

## License

MIT