# 差分同期

> フル同期を実行し、nextSyncToken を使ってイベントの変更差分や削除を取得します。

Calendarpit は、Google Calendar の `events.list` におけるフル同期と差分同期のフローを実装しています。流れは次の 2 段階です。

1. 最初にフル同期を実行して `nextSyncToken` を取得します。
2. 以降のリクエストで、そのトークンを `syncToken` として渡します。その時点以降に作成・更新・削除されたイベントだけを取得できます。

プロトコルの詳細は Google の[同期ガイド](https://developers.google.com/workspace/calendar/api/guides/sync)および [`events.list` リファレンス](https://developers.google.com/workspace/calendar/api/v3/reference/events/list)を参照してください。

## フル同期

`events.list` によるフル同期（`events.instances` は対象外）では、`showDeleted=true` の有無にかかわらず同期トークンが発行されます。

### ページ送り

- 最終ページまで `nextPageToken` をたどってください。
- 各 `pageToken` のリクエストには、初回と同じクエリパラメータを渡します。パラメータを変更すると HTTP `410`（`invalidPageToken`）が返ります。
- Calendarpit は最終ページでのみ `nextSyncToken` を返し、`nextPageToken` と同時に返すことはありません。全ページを処理した後に `nextSyncToken` を保存してください。
- 各ページのレコードと同期用のスナップショットは、最初のページを取得した時点でまとめて固定されます。ページ送りの最中に発生したイベントの書き込みは、次回の差分同期で返されます。

### 終了条件のない繰り返しの系列

> [!WARNING]
>
> `singleEvents=true` を指定する場合は、フル同期で必ず `timeMax` を設定してください。
>

終了条件のない繰り返しの系列とは、`COUNT` も `UNTIL` もない `RRULE` のことです。このような系列がカレンダーにあると、次のリクエストは失敗します。

- **`singleEvents=true`**: `timeMax` なしで発行されたトークンを使うと、HTTP `400`（`unsupportedFeature`）が返ります。`timeMax` を指定してフル同期をやり直してください。
- **`singleEvents=false`**: `timeMin` のみを指定し `timeMax` を省略したフル同期の `events.list` も拒否されます（[イベント、繰り返し、時刻の扱い](https://calendarpit.dev/ja/recurrence.md)を参照）。`singleEvents=false` での差分同期はこの影響を受けません。

終了条件のない繰り返しの系列は、削除後も対象になります。リセットや Fixture の読み込みを行うまで、削除済みレコード（`status: cancelled`）としてカレンダーに残るためです。

```ts
// api is the Google SDK client configured with Calendarpit's rootUrl.
const query = {
  calendarId: 'primary',
  singleEvents: true,
  showDeleted: true,
  timeMin: '2026-10-01T00:00:00Z',
  timeMax: '2026-11-01T00:00:00Z',
};
let pageToken: string | undefined;
let syncToken: string | undefined;
do {
  const { data } = await api.events.list({ ...query, pageToken });
  for (const event of data.items ?? []) {
    if (event.status === 'cancelled') await removeLocalEvent(event.id!);
    else await upsertLocalEvent(event);
  }
  pageToken = data.nextPageToken ?? undefined;
  if (!pageToken) syncToken = data.nextSyncToken ?? undefined;
} while (pageToken);
```

## 差分同期

[watch 通知](https://calendarpit.dev/ja/watch.md)を受け取った後や定期的なポーリング時に、`syncToken` を指定して `events.list` を呼び出します。差分同期の結果も `maxResults` と `pageToken` でページ送りできます。

```ts
const previousToken = syncToken;
do {
  const { data } = await api.events.list({
    calendarId: 'primary',
    singleEvents: true,
    showDeleted: true,
    syncToken: previousToken,
    pageToken,
  });
  for (const event of data.items ?? []) {
    if (event.status === 'cancelled') await removeLocalEvent(event.id!);
    else await upsertLocalEvent(event);
  }
  pageToken = data.nextPageToken ?? undefined;
  if (!pageToken) syncToken = data.nextSyncToken ?? undefined;
} while (pageToken);
```

### クエリのルール

以下のルールに違反するリクエストは HTTP `400`（`invalidParameter`）で失敗します。

- **併用できないパラメータ**: `syncToken` は `timeMin`、`timeMax`、`orderBy`、`updatedMin`、`privateExtendedProperty`、`sharedExtendedProperty`、`iCalUID` と同時に指定できません。また、`events.instances` は `syncToken` に対応していません。
- **削除されたイベント**: `showDeleted=false` は指定しないでください。差分同期には常に削除が含まれます。`showDeleted=true` を指定するか、`showDeleted` を省略してください。
- **展開オプションの一致**: `singleEvents` と適用される `timeZone` は、初回のフル同期と一致している必要があります。適用される `timeZone` は、`timeZone` パラメータの値です。省略時はカレンダーのタイムゾーンです。
- **タイムゾーンの変更**: `timeZone` パラメータを明示せずに発行されたトークンは、管理画面や管理 API でカレンダーのタイムゾーンを変更すると無効になります（HTTP `400`）。

### 差分の内容と挙動

- **リトライ**: 同期トークンは一度使っても消費されません。リクエストが失敗した場合は、同じ `syncToken` でリトライできます。
- **毎回新しいトークン**: 差分同期を実行するたびに、最終ページで新しい `nextSyncToken` が返されます。
- **変更がない場合**: `items` が `[]` になります。この場合も新しい `nextSyncToken` が返されます。
- **削除済みレコード**: 削除されたイベントは削除済みレコードとして保持され、`status: "cancelled"` で返されます。
- **フィルタの非適用**: 差分には、初回リクエストで指定した `updatedMin`、拡張プロパティ、`iCalUID` のフィルタは適用されません。カレンダー内で変更されたすべてのイベントが含まれます。
- **繰り返しの系列の対象期間**: 差分の内容は `singleEvents` によって変わります。
  - `singleEvents=false` の場合、差分には変更された繰り返しの系列の親イベント、保存された例外（個別に変更された回）、単発のイベントが含まれます。
  - `singleEvents=true` の場合、繰り返しの系列は初回のフル同期で指定した `timeMin` と `timeMax` の期間内で再展開されます。親イベントを編集すると、その期間内のすべてのインスタンスが返されます。系列の期間短縮やキャンセルで除外されたインスタンスは、`status: "cancelled"` として返されます。
  - 繰り返しではない単発のイベントは、初回の `timeMin` と `timeMax` の期間外でも、変更があれば返されます。

  繰り返しの展開期間を移動する場合は、新しくフル同期を実行してください。

## ライフサイクルと制限

同期トークンは、ユーザー、カレンダー、展開オプション、初回の対象期間に紐づく不透明な UUID です。

| トークン    | 有効期間と上限                                                        | 永続化                                             | 無効時のエラー                   |
| ----------- | --------------------------------------------------------------------- | -------------------------------------------------- | -------------------------------- |
| `syncToken` | TTL 7 日間、ワークスペースあたり最大 100 件（古いものから削除）       | SQLite に保存（`:memory:` モードでは再起動で消失） | HTTP `410`（`fullSyncRequired`） |
| `pageToken` | TTL 1 時間、ワークスペースあたり最大 1,000 ページ（古いものから削除） | インメモリのみ（再起動で消失）                     | HTTP `410`（`invalidPageToken`） |

- **HTTP `410`（`fullSyncRequired`）**: `syncToken` が次のいずれかに当てはまる場合に返されます。
  - 不明、または期限切れ
  - 上限の 100 件を超えて削除された
  - リセットや Fixture の読み込みで消去された
  - 別のユーザーやカレンダー向けに発行された

  このエラーが返された場合は、そのカレンダーのローカル同期データを破棄してフル同期をやり直してください。

- **トークンの上限**: `pageToken` を指定しないフル同期・差分同期の `events.list` リクエストごとに、新しいトークンが発行されます。上限の 100 件は、すべてのユーザーとカレンダーで共有されます。管理 API でのイベント一覧取得も含みます。
- **リセットと Fixture**: `POST /__calendarpit/reset` および `POST /__calendarpit/fixtures` を実行すると、すべての同期トークンとページトークンが消去されます（[テスト制御 API](https://calendarpit.dev/ja/test-control.md)を参照）。同時にすべての OAuth 認可も取り消されるため、クライアントは先に再認可を行う必要があります。
- **ページカーソル**: イベントの `pageToken` カーソルは、クエリパラメータと呼び出し元のユーザーに紐づいています。再起動、リセット、Fixture の読み込みを行うと失われます。HTTP `410`（`invalidPageToken`）が返された場合は、保持している `syncToken` からページ送りをやり直すか、フル同期を最初からやり直してください。
