@kubun/connector-google-calendar 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/api.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { EntityRecord } from '@kubun/connector';
2
+ export declare function parseGoogleError(response: Response): Promise<string>;
2
3
  export type GoogleCalendarResource = {
3
4
  id: string;
4
5
  summary: string;
package/lib/api.js CHANGED
@@ -1,5 +1,5 @@
1
1
  const CALENDAR_BASE_URL = 'https://www.googleapis.com/calendar/v3';
2
- async function parseGoogleError(response) {
2
+ export async function parseGoogleError(response) {
3
3
  try {
4
4
  const body = await response.json();
5
5
  return body.error?.message ?? `${response.status} ${response.statusText}`;
@@ -9,13 +9,19 @@ async function parseGoogleError(response) {
9
9
  }
10
10
  export function buildCalendarListURL(params) {
11
11
  const url = new URL(`${CALENDAR_BASE_URL}/users/me/calendarList`);
12
- url.searchParams.set('showHidden', 'false');
12
+ // Google rejects `showHidden=false` (and `showDeleted=false`) alongside a
13
+ // syncToken: an incremental list must not narrow the result set the token was
14
+ // issued against. A paged continuation (pageToken) inherits the first page's
15
+ // context, so only the very first, tokenless page constrains visibility.
13
16
  if (params.syncToken != null) {
14
17
  url.searchParams.set('syncToken', params.syncToken);
15
18
  }
16
19
  if (params.pageToken != null) {
17
20
  url.searchParams.set('pageToken', params.pageToken);
18
21
  }
22
+ if (params.syncToken == null && params.pageToken == null) {
23
+ url.searchParams.set('showHidden', 'false');
24
+ }
19
25
  return url.toString();
20
26
  }
21
27
  export function buildEventsListURL(params) {
@@ -43,10 +49,16 @@ export function mapCalendarToEntity(calendar) {
43
49
  backgroundColor: calendar.backgroundColor
44
50
  };
45
51
  }
52
+ // Google gives all-day events a bare calendar date (`YYYY-MM-DD`); the model's
53
+ // startAt/endAt are `date-time`, so a bare date fails schema validation. Anchor
54
+ // it to midnight UTC — the allDay flag tells consumers to ignore the time.
55
+ function allDayDateToDateTime(date) {
56
+ return date == null ? undefined : `${date}T00:00:00.000Z`;
57
+ }
46
58
  export function mapEventToEntity(event, calendarID) {
47
59
  const allDay = event.start.date != null;
48
- const startAt = allDay ? event.start.date : event.start.dateTime;
49
- const endAt = allDay ? event.end.date : event.end.dateTime;
60
+ const startAt = allDay ? allDayDateToDateTime(event.start.date) : event.start.dateTime;
61
+ const endAt = allDay ? allDayDateToDateTime(event.end.date) : event.end.dateTime;
50
62
  return {
51
63
  sourceService: 'google-calendar',
52
64
  sourceID: event.id,
package/lib/model.d.ts CHANGED
@@ -48,7 +48,11 @@ export declare const googleCalendarDefinition: {
48
48
  readonly minLength: 1;
49
49
  readonly maxLength: 500;
50
50
  };
51
- readonly SourceURL: import("json-schema-typed").JSONSchema.String;
51
+ readonly SourceURL: {
52
+ readonly type: "string";
53
+ readonly format: "uri";
54
+ readonly title: "URL";
55
+ };
52
56
  readonly DateTime: import("json-schema-typed").JSONSchema.String;
53
57
  readonly TimeZone: import("json-schema-typed").JSONSchema.String;
54
58
  };
@@ -128,11 +132,23 @@ export declare const googleCalendarEventDefinition: {
128
132
  readonly minLength: 1;
129
133
  readonly maxLength: 500;
130
134
  };
131
- readonly SourceURL: import("json-schema-typed").JSONSchema.String;
135
+ readonly SourceURL: {
136
+ readonly type: "string";
137
+ readonly format: "uri";
138
+ readonly title: "URL";
139
+ };
132
140
  readonly DateTime: import("json-schema-typed").JSONSchema.String;
133
141
  readonly TimeZone: import("json-schema-typed").JSONSchema.String;
134
- readonly HtmlLink: import("json-schema-typed").JSONSchema.String;
135
- readonly CalendarDocID: import("json-schema-typed").JSONSchema.String;
142
+ readonly HtmlLink: {
143
+ readonly type: "string";
144
+ readonly format: "uri";
145
+ readonly title: "URL";
146
+ };
147
+ readonly CalendarDocID: {
148
+ readonly type: "string";
149
+ readonly title: "DocID";
150
+ readonly pattern: "^k[0-9a-z]{10,120}$";
151
+ };
136
152
  readonly EventStatus: {
137
153
  readonly type: "string";
138
154
  readonly title: "EventStatus";
package/lib/provider.js CHANGED
@@ -1,8 +1,19 @@
1
1
  import { resolveModelID } from '@kubun/connector';
2
- import { buildCalendarListURL, buildEventsListURL, mapCalendarToEntity, mapEventToEntity } from './api.js';
2
+ import { buildCalendarListURL, buildEventsListURL, mapCalendarToEntity, mapEventToEntity, parseGoogleError } from './api.js';
3
3
  import { googleCalendarClusters } from './model.js';
4
4
  const CALENDAR_MODEL_ID = resolveModelID(googleCalendarClusters, 'GoogleCalendar');
5
5
  const CALENDAR_EVENT_MODEL_ID = resolveModelID(googleCalendarClusters, 'GoogleCalendarEvent');
6
+ class GoogleCalendarAPIError extends Error {
7
+ #status;
8
+ constructor(status, detail){
9
+ super(`Google Calendar API error: ${detail}`);
10
+ this.name = 'GoogleCalendarAPIError';
11
+ this.#status = status;
12
+ }
13
+ get status() {
14
+ return this.#status;
15
+ }
16
+ }
6
17
  async function fetchJSON(url, credential, fetchFn, signal) {
7
18
  const response = await fetchFn(url, {
8
19
  headers: {
@@ -11,7 +22,7 @@ async function fetchJSON(url, credential, fetchFn, signal) {
11
22
  signal
12
23
  });
13
24
  if (!response.ok) {
14
- throw new Error(`Google Calendar API error: ${response.status} ${response.statusText}`);
25
+ throw new GoogleCalendarAPIError(response.status, await parseGoogleError(response));
15
26
  }
16
27
  return await response.json();
17
28
  }
@@ -151,131 +162,144 @@ export function createGoogleCalendarProvider(config) {
151
162
  };
152
163
  }
153
164
  }
154
- return {
155
- supportedStrategies: [
156
- 'on-demand'
157
- ],
158
- fetchAll: fetchAllEntities,
159
- async *fetchChanges (params) {
160
- const checkpoint = params.checkpoint;
161
- if (checkpoint == null) {
162
- yield* fetchAllEntities(params);
163
- return;
165
+ async function* fetchIncrementalEntities(params, checkpoint) {
166
+ const timeMin = computeTimeMin(params.boundary);
167
+ // Phase 1: fetch calendar list changes using stored syncToken
168
+ const activeCalendars = [];
169
+ const deletedCalendarIDs = [];
170
+ let newCalendarListSyncToken;
171
+ for await (const page of fetchAllCalendars({
172
+ credential: config.credential,
173
+ fetchFn,
174
+ syncToken: checkpoint.calendarListSyncToken,
175
+ signal: params.signal
176
+ })){
177
+ for (const cal of page.items){
178
+ if (cal.deleted === true) {
179
+ deletedCalendarIDs.push(cal.id);
180
+ } else if (!excludeSet.has(cal.id)) {
181
+ activeCalendars.push(cal);
182
+ }
183
+ }
184
+ if (page.nextSyncToken != null) {
185
+ newCalendarListSyncToken = page.nextSyncToken;
164
186
  }
165
- const timeMin = computeTimeMin(params.boundary);
166
- // Phase 1: fetch calendar list changes using stored syncToken
167
- const activeCalendars = [];
168
- const deletedCalendarIDs = [];
169
- let newCalendarListSyncToken;
170
- for await (const page of fetchAllCalendars({
187
+ }
188
+ // Yield calendar batch (active + deletions)
189
+ yield {
190
+ modelID: CALENDAR_MODEL_ID,
191
+ entities: activeCalendars.map(mapCalendarToEntity),
192
+ deleted: deletedCalendarIDs.map((id)=>({
193
+ sourceService: 'google-calendar',
194
+ sourceID: id
195
+ })),
196
+ hasMore: true
197
+ };
198
+ // Phase 2: determine the set of calendars to sync events for
199
+ // Start from the previous calendar set, remove deleted, add newly discovered active ones
200
+ const prevCalendarTokens = {
201
+ ...checkpoint.calendars
202
+ };
203
+ for (const deletedID of deletedCalendarIDs){
204
+ delete prevCalendarTokens[deletedID];
205
+ }
206
+ // Collect new calendars that weren't in the previous checkpoint
207
+ const newlyDiscoveredCalendars = activeCalendars.filter((cal)=>!(cal.id in checkpoint.calendars));
208
+ // All calendars to sync: known (from prev checkpoint, minus deleted) + newly discovered
209
+ const knownCalendarIDs = Object.keys(prevCalendarTokens);
210
+ // Combine: known calendar IDs (we don't have resource objects for them, only IDs)
211
+ // plus newly discovered calendar resources
212
+ const allCalendarIDsToSync = [
213
+ ...knownCalendarIDs.map((id)=>({
214
+ id,
215
+ isNew: false
216
+ })),
217
+ ...newlyDiscoveredCalendars.map((cal)=>({
218
+ id: cal.id,
219
+ isNew: true
220
+ }))
221
+ ];
222
+ const updatedCalendarTokens = {};
223
+ for(let i = 0; i < allCalendarIDsToSync.length; i++){
224
+ const { id: calendarID, isNew } = allCalendarIDsToSync[i];
225
+ const isLastCalendar = i === allCalendarIDsToSync.length - 1;
226
+ // Use stored syncToken for known calendars; use timeMin for new ones
227
+ const storedToken = isNew ? undefined : prevCalendarTokens[calendarID]?.eventsSyncToken;
228
+ let eventsSyncToken;
229
+ for await (const page of fetchEventsForCalendar({
230
+ calendarID,
171
231
  credential: config.credential,
172
232
  fetchFn,
173
- syncToken: checkpoint.calendarListSyncToken,
233
+ syncToken: storedToken,
234
+ timeMin: isNew ? timeMin : undefined,
174
235
  signal: params.signal
175
236
  })){
176
- for (const cal of page.items){
177
- if (cal.deleted === true) {
178
- deletedCalendarIDs.push(cal.id);
179
- } else if (!excludeSet.has(cal.id)) {
180
- activeCalendars.push(cal);
181
- }
182
- }
183
237
  if (page.nextSyncToken != null) {
184
- newCalendarListSyncToken = page.nextSyncToken;
238
+ eventsSyncToken = page.nextSyncToken;
185
239
  }
240
+ const isLastPage = page.nextSyncToken != null;
241
+ const newCheckpoint = isLastCalendar && isLastPage ? {
242
+ calendarListSyncToken: newCalendarListSyncToken,
243
+ calendars: {
244
+ ...updatedCalendarTokens,
245
+ [calendarID]: {
246
+ eventsSyncToken: page.nextSyncToken
247
+ }
248
+ }
249
+ } : undefined;
250
+ // Split items: cancelled → deleted, others → entities
251
+ const activeItems = page.items.filter((e)=>e.status !== 'cancelled');
252
+ const cancelledItems = page.items.filter((e)=>e.status === 'cancelled');
253
+ yield {
254
+ modelID: CALENDAR_EVENT_MODEL_ID,
255
+ entities: activeItems.map((event)=>mapEventToEntity(event, calendarID)),
256
+ deleted: cancelledItems.map((e)=>({
257
+ sourceService: 'google-calendar',
258
+ sourceID: e.id
259
+ })),
260
+ hasMore: !(isLastCalendar && isLastPage),
261
+ checkpoint: newCheckpoint
262
+ };
186
263
  }
187
- // Yield calendar batch (active + deletions)
188
- yield {
189
- modelID: CALENDAR_MODEL_ID,
190
- entities: activeCalendars.map(mapCalendarToEntity),
191
- deleted: deletedCalendarIDs.map((id)=>({
192
- sourceService: 'google-calendar',
193
- sourceID: id
194
- })),
195
- hasMore: true
264
+ updatedCalendarTokens[calendarID] = {
265
+ eventsSyncToken
196
266
  };
197
- // Phase 2: determine the set of calendars to sync events for
198
- // Start from the previous calendar set, remove deleted, add newly discovered active ones
199
- const prevCalendarTokens = {
200
- ...checkpoint.calendars
267
+ }
268
+ // If no event batches were yielded (zero calendars to sync), emit a final empty
269
+ // batch with hasMore: false and the composite checkpoint so the sync can complete.
270
+ if (allCalendarIDsToSync.length === 0) {
271
+ yield {
272
+ modelID: CALENDAR_EVENT_MODEL_ID,
273
+ entities: [],
274
+ hasMore: false,
275
+ checkpoint: {
276
+ calendarListSyncToken: newCalendarListSyncToken,
277
+ calendars: updatedCalendarTokens
278
+ }
201
279
  };
202
- for (const deletedID of deletedCalendarIDs){
203
- delete prevCalendarTokens[deletedID];
280
+ }
281
+ }
282
+ return {
283
+ supportedStrategies: [
284
+ 'on-demand'
285
+ ],
286
+ fetchAll: fetchAllEntities,
287
+ async *fetchChanges (params) {
288
+ const checkpoint = params.checkpoint;
289
+ if (checkpoint == null) {
290
+ yield* fetchAllEntities(params);
291
+ return;
204
292
  }
205
- // Collect new calendars that weren't in the previous checkpoint
206
- const newlyDiscoveredCalendars = activeCalendars.filter((cal)=>!(cal.id in checkpoint.calendars));
207
- // All calendars to sync: known (from prev checkpoint, minus deleted) + newly discovered
208
- const knownCalendarIDs = Object.keys(prevCalendarTokens);
209
- // Combine: known calendar IDs (we don't have resource objects for them, only IDs)
210
- // plus newly discovered calendar resources
211
- const allCalendarIDsToSync = [
212
- ...knownCalendarIDs.map((id)=>({
213
- id,
214
- isNew: false
215
- })),
216
- ...newlyDiscoveredCalendars.map((cal)=>({
217
- id: cal.id,
218
- isNew: true
219
- }))
220
- ];
221
- const updatedCalendarTokens = {};
222
- for(let i = 0; i < allCalendarIDsToSync.length; i++){
223
- const { id: calendarID, isNew } = allCalendarIDsToSync[i];
224
- const isLastCalendar = i === allCalendarIDsToSync.length - 1;
225
- // Use stored syncToken for known calendars; use timeMin for new ones
226
- const storedToken = isNew ? undefined : prevCalendarTokens[calendarID]?.eventsSyncToken;
227
- let eventsSyncToken;
228
- for await (const page of fetchEventsForCalendar({
229
- calendarID,
230
- credential: config.credential,
231
- fetchFn,
232
- syncToken: storedToken,
233
- timeMin: isNew ? timeMin : undefined,
234
- signal: params.signal
235
- })){
236
- if (page.nextSyncToken != null) {
237
- eventsSyncToken = page.nextSyncToken;
238
- }
239
- const isLastPage = page.nextSyncToken != null;
240
- const newCheckpoint = isLastCalendar && isLastPage ? {
241
- calendarListSyncToken: newCalendarListSyncToken,
242
- calendars: {
243
- ...updatedCalendarTokens,
244
- [calendarID]: {
245
- eventsSyncToken: page.nextSyncToken
246
- }
247
- }
248
- } : undefined;
249
- // Split items: cancelled → deleted, others → entities
250
- const activeItems = page.items.filter((e)=>e.status !== 'cancelled');
251
- const cancelledItems = page.items.filter((e)=>e.status === 'cancelled');
252
- yield {
253
- modelID: CALENDAR_EVENT_MODEL_ID,
254
- entities: activeItems.map((event)=>mapEventToEntity(event, calendarID)),
255
- deleted: cancelledItems.map((e)=>({
256
- sourceService: 'google-calendar',
257
- sourceID: e.id
258
- })),
259
- hasMore: !(isLastCalendar && isLastPage),
260
- checkpoint: newCheckpoint
261
- };
293
+ try {
294
+ yield* fetchIncrementalEntities(params, checkpoint);
295
+ } catch (err) {
296
+ // 410 Gone = the stored syncToken has expired; Google requires a full
297
+ // resync. Discard the checkpoint by delegating to fetchAll.
298
+ if (err instanceof GoogleCalendarAPIError && err.status === 410) {
299
+ yield* fetchAllEntities(params);
300
+ return;
262
301
  }
263
- updatedCalendarTokens[calendarID] = {
264
- eventsSyncToken
265
- };
266
- }
267
- // If no event batches were yielded (zero calendars to sync), emit a final empty
268
- // batch with hasMore: false and the composite checkpoint so the sync can complete.
269
- if (allCalendarIDsToSync.length === 0) {
270
- yield {
271
- modelID: CALENDAR_EVENT_MODEL_ID,
272
- entities: [],
273
- hasMore: false,
274
- checkpoint: {
275
- calendarListSyncToken: newCalendarListSyncToken,
276
- calendars: updatedCalendarTokens
277
- }
278
- };
302
+ throw err;
279
303
  }
280
304
  }
281
305
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/connector-google-calendar",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "keywords": [],
5
5
  "license": "see LICENSE.md",
6
6
  "sideEffects": false,
@@ -15,11 +15,11 @@
15
15
  "LICENSE.md"
16
16
  ],
17
17
  "dependencies": {
18
- "@kubun/connector": "^0.12.1",
19
- "@kubun/models": "^0.12.1",
18
+ "@kubun/connector": "^0.13.0",
20
19
  "@kubun/protocol": "^0.13.0",
21
- "@kubun/provider-google": "^0.12.0",
22
- "@kubun/scalars": "^0.12.0"
20
+ "@kubun/scalars": "^0.13.0",
21
+ "@kubun/models": "^0.13.0",
22
+ "@kubun/provider-google": "^0.13.0"
23
23
  },
24
24
  "publishConfig": {
25
25
  "access": "public"