@kubun/connector-google-calendar 0.17.0 → 0.17.2

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/index.d.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  export { createGoogleCalendarActionHandler, type GoogleCalendarActionHandlerConfig, } from './actions.js';
2
2
  export { buildCalendarListURL, buildEventFromParams, buildEventsListURL, createEvent, type GoogleCalendarListResponse, type GoogleCalendarResource, type GoogleEventDateTime, type GoogleEventResource, type GoogleEventsListResponse, mapCalendarToEntity, mapEventToEntity, updateEvent, } from './api.js';
3
3
  export { type CreateGoogleCalendarConnectorOptions, createGoogleCalendarConnector, } from './connector.js';
4
+ export { GoogleCalendarAPIError, GoogleCalendarError, type GoogleCalendarErrorCode, type GoogleCalendarErrorOptions, GoogleCalendarEventConflict, } from './errors.js';
5
+ export { deriveEventID } from './event-id.js';
6
+ export { canonicalizeEventIntent, eventHoldsIntent } from './intent.js';
4
7
  export { googleCalendarClusters, googleCalendarDefinition, googleCalendarEventDefinition, } from './model.js';
8
+ export { predictCreatedEventSourceID } from './predict.js';
5
9
  export { type CalendarCheckpoint, createGoogleCalendarProvider, type GoogleCalendarProviderConfig, } from './provider.js';
10
+ export { resolveCalendarTarget } from './target.js';
package/lib/index.js CHANGED
@@ -1,5 +1,10 @@
1
1
  export { createGoogleCalendarActionHandler } from './actions.js';
2
2
  export { buildCalendarListURL, buildEventFromParams, buildEventsListURL, createEvent, mapCalendarToEntity, mapEventToEntity, updateEvent } from './api.js';
3
3
  export { createGoogleCalendarConnector } from './connector.js';
4
+ export { GoogleCalendarAPIError, GoogleCalendarError, GoogleCalendarEventConflict } from './errors.js';
5
+ export { deriveEventID } from './event-id.js';
6
+ export { canonicalizeEventIntent, eventHoldsIntent } from './intent.js';
4
7
  export { googleCalendarClusters, googleCalendarDefinition, googleCalendarEventDefinition } from './model.js';
8
+ export { predictCreatedEventSourceID } from './predict.js';
5
9
  export { createGoogleCalendarProvider } from './provider.js';
10
+ export { resolveCalendarTarget } from './target.js';
@@ -0,0 +1,23 @@
1
+ import type { EntityRecord, IntentCanonicalizer } from '@kubun/connector';
2
+ /**
3
+ * The Calendar intent canonicalizer (see `IntentCanonicalizer`). Folds in only
4
+ * what Google itself does to a written event:
5
+ * - a start/end is compared as the instant (or, all-day, the date) it names,
6
+ * only when it names one by itself (an explicit offset), and an all-day
7
+ * value never matches a timed one;
8
+ * - `calendarID` is compared exactly: an operation's intent already carries
9
+ * the calendar the `'primary'` alias was resolved to before the claim;
10
+ * - a cancelled (deleted) event holds no create or update.
11
+ */
12
+ export declare const canonicalizeEventIntent: IntentCanonicalizer;
13
+ /**
14
+ * Whether a Calendar event holds a write's intent: every event field the
15
+ * intent sets carries the intended value, as Google keeps it. Keys of the
16
+ * intent that are not event fields (a target, say) are not compared, and an
17
+ * intent that sets no event field does not hold.
18
+ */
19
+ export declare function eventHoldsIntent(params: {
20
+ op: 'create' | 'update';
21
+ intent: Record<string, unknown>;
22
+ record: EntityRecord;
23
+ }): boolean;
package/lib/intent.js ADDED
@@ -0,0 +1,86 @@
1
+ import { digestJSON } from '@kubun/id';
2
+ import { toB64U } from '@sozai/codec';
3
+ import { googleCalendarEventDefinition } from './model.js';
4
+ const EVENT_FIELDS = new Set(googleCalendarEventDefinition.providerMergePolicy.group);
5
+ const BARE_DATE = /^\d{4}-\d{2}-\d{2}$/;
6
+ const DATE_PREFIX = /^\d{4}-\d{2}-\d{2}/;
7
+ // A date-time that names its instant by itself: an explicit `Z` or `±hh:mm`.
8
+ const WITH_OFFSET = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/i;
9
+ // A start/end in the form Google keeps it. An all-day value is its calendar
10
+ // date (Google stores only the date) — kept a string, so it never equals a
11
+ // timed instant. A timed value with an explicit offset is the instant it names
12
+ // (Google re-renders a dateTime in the calendar's offset). Anything else — an
13
+ // offset-less date-time, which would otherwise be read in the host's time
14
+ // zone — stays as it is and is compared exactly.
15
+ function canonicalDateTime(value, allDay) {
16
+ if (typeof value !== 'string') return value;
17
+ if (allDay) return DATE_PREFIX.test(value) ? value.slice(0, 10) : value;
18
+ return WITH_OFFSET.test(value) ? Date.parse(value) : value;
19
+ }
20
+ /**
21
+ * The Calendar intent canonicalizer (see `IntentCanonicalizer`). Folds in only
22
+ * what Google itself does to a written event:
23
+ * - a start/end is compared as the instant (or, all-day, the date) it names,
24
+ * only when it names one by itself (an explicit offset), and an all-day
25
+ * value never matches a timed one;
26
+ * - `calendarID` is compared exactly: an operation's intent already carries
27
+ * the calendar the `'primary'` alias was resolved to before the claim;
28
+ * - a cancelled (deleted) event holds no create or update.
29
+ */ export const canonicalizeEventIntent = ({ model, intent, record })=>{
30
+ if (model !== googleCalendarEventDefinition.name) return {
31
+ intent,
32
+ record
33
+ };
34
+ if (record.status === 'CANCELLED') return null;
35
+ // Each side is read in its own shape: an all-day intent (declared, or a
36
+ // bare date) as dates, the record by its own allDay flag. A timed intent
37
+ // against an all-day event, or the reverse, therefore never compares equal.
38
+ const intentAllDay = intent.allDay === true;
39
+ const recordAllDay = record.allDay === true;
40
+ const canonicalIntent = {
41
+ ...intent
42
+ };
43
+ const canonicalRecord = {
44
+ ...record
45
+ };
46
+ for (const field of [
47
+ 'startAt',
48
+ 'endAt'
49
+ ]){
50
+ if (field in canonicalIntent) {
51
+ const value = canonicalIntent[field];
52
+ const asDate = intentAllDay || typeof value === 'string' && BARE_DATE.test(value);
53
+ canonicalIntent[field] = canonicalDateTime(value, asDate);
54
+ }
55
+ canonicalRecord[field] = canonicalDateTime(canonicalRecord[field], recordAllDay);
56
+ }
57
+ return {
58
+ intent: canonicalIntent,
59
+ record: canonicalRecord
60
+ };
61
+ };
62
+ function canonicalOf(value) {
63
+ return toB64U(digestJSON(value === undefined ? null : value));
64
+ }
65
+ // An intent that sets no event field confirms nothing, and does not hold.
66
+ function holds(compared) {
67
+ let matched = 0;
68
+ for (const [field, intended] of Object.entries(compared.intent)){
69
+ if (!EVENT_FIELDS.has(field)) continue;
70
+ if (canonicalOf(compared.record[field]) !== canonicalOf(intended)) return false;
71
+ matched++;
72
+ }
73
+ return matched > 0;
74
+ }
75
+ /**
76
+ * Whether a Calendar event holds a write's intent: every event field the
77
+ * intent sets carries the intended value, as Google keeps it. Keys of the
78
+ * intent that are not event fields (a target, say) are not compared, and an
79
+ * intent that sets no event field does not hold.
80
+ */ export function eventHoldsIntent(params) {
81
+ const compared = canonicalizeEventIntent({
82
+ model: googleCalendarEventDefinition.name,
83
+ ...params
84
+ });
85
+ return compared != null && holds(compared);
86
+ }
package/lib/model.d.ts CHANGED
@@ -20,6 +20,11 @@ export declare const googleCalendarDefinition: {
20
20
  readonly backgroundColor: {
21
21
  readonly type: "string";
22
22
  };
23
+ readonly accountKey: {
24
+ readonly type: "string";
25
+ readonly minLength: 43;
26
+ readonly maxLength: 43;
27
+ };
23
28
  readonly sourceService: {
24
29
  readonly $ref: "#/definitions/SourceService";
25
30
  };
@@ -104,6 +109,9 @@ export declare const googleCalendarEventDefinition: {
104
109
  readonly calendarID: {
105
110
  readonly type: "string";
106
111
  };
112
+ readonly etag: {
113
+ readonly type: "string";
114
+ };
107
115
  readonly calendar: {
108
116
  readonly $ref: "#/definitions/CalendarDocID";
109
117
  };
@@ -165,5 +173,8 @@ export declare const googleCalendarEventDefinition: {
165
173
  readonly model: "#0";
166
174
  };
167
175
  };
176
+ readonly providerMergePolicy: {
177
+ readonly group: ["title", "startAt", "endAt", "allDay", "timeZone", "description", "location", "status", "htmlLink", "recurringEventID", "eventID", "calendarID", "calendar", "sourceService", "sourceID", "sourceURL", "etag"];
178
+ };
168
179
  };
169
180
  export declare const googleCalendarClusters: import("@kubun/protocol").ClustersRecord;
package/lib/model.js CHANGED
@@ -26,6 +26,11 @@ export const googleCalendarDefinition = {
26
26
  backgroundColor: {
27
27
  type: 'string'
28
28
  },
29
+ accountKey: {
30
+ type: 'string',
31
+ minLength: 43,
32
+ maxLength: 43
33
+ },
29
34
  sourceService: {
30
35
  $ref: '#/definitions/SourceService'
31
36
  },
@@ -114,6 +119,9 @@ export const googleCalendarEventDefinition = {
114
119
  calendarID: {
115
120
  type: 'string'
116
121
  },
122
+ etag: {
123
+ type: 'string'
124
+ },
117
125
  calendar: {
118
126
  $ref: '#/definitions/CalendarDocID'
119
127
  },
@@ -177,6 +185,32 @@ export const googleCalendarEventDefinition = {
177
185
  type: 'document',
178
186
  model: '#0'
179
187
  }
188
+ },
189
+ // lastSyncedAt is a device-local sync-bookkeeping annotation, not fetched
190
+ // provider content, so it stays outside the group.
191
+ providerMergePolicy: {
192
+ group: [
193
+ 'title',
194
+ 'startAt',
195
+ 'endAt',
196
+ 'allDay',
197
+ 'timeZone',
198
+ 'description',
199
+ 'location',
200
+ 'status',
201
+ 'htmlLink',
202
+ 'recurringEventID',
203
+ 'eventID',
204
+ 'calendarID',
205
+ 'calendar',
206
+ 'sourceService',
207
+ 'sourceID',
208
+ 'sourceURL',
209
+ // The provider revision of this state: an update is written
210
+ // conditionally on it (If-Match), so it must travel with the fields it
211
+ // versions.
212
+ 'etag'
213
+ ]
180
214
  }
181
215
  };
182
216
  const builder = new GraphBuilder({
@@ -0,0 +1,8 @@
1
+ import type { CreatedSourceIDPredictor } from '@kubun/connector';
2
+ /**
3
+ * The provider id a calendar event create is stored under: the resolved
4
+ * calendar and the event id derived from the operation's deterministic event
5
+ * id, as the create handler inserts it. `null` for a calendar create and for
6
+ * an input whose calendar is not resolved.
7
+ */
8
+ export declare const predictCreatedEventSourceID: CreatedSourceIDPredictor;
package/lib/predict.js ADDED
@@ -0,0 +1,12 @@
1
+ import { deriveEventID } from './event-id.js';
2
+ /**
3
+ * The provider id a calendar event create is stored under: the resolved
4
+ * calendar and the event id derived from the operation's deterministic event
5
+ * id, as the create handler inserts it. `null` for a calendar create and for
6
+ * an input whose calendar is not resolved.
7
+ */ export const predictCreatedEventSourceID = ({ model, input, deterministicEventID })=>{
8
+ if (model !== 'GoogleCalendarEvent') return null;
9
+ const calendarID = input.calendarID;
10
+ if (typeof calendarID !== 'string' || calendarID === '' || calendarID === 'primary') return null;
11
+ return `${calendarID}/${deriveEventID(deterministicEventID)}`;
12
+ };
package/lib/provider.d.ts CHANGED
@@ -5,11 +5,13 @@ export type GoogleCalendarProviderConfig = {
5
5
  boundary?: SyncBoundary;
6
6
  fetch?: typeof globalThis.fetch;
7
7
  excludeCalendars?: Array<string>;
8
+ mintFetchHLC: () => string;
8
9
  };
9
10
  export type CalendarCheckpoint = {
10
11
  calendarListSyncToken?: string;
11
12
  calendars: Record<string, {
12
13
  eventsSyncToken?: string;
13
14
  }>;
15
+ windowStart?: string;
14
16
  };
15
17
  export declare function createGoogleCalendarProvider(config: GoogleCalendarProviderConfig): ServerDataProvider;
package/lib/provider.js CHANGED
@@ -1,19 +1,10 @@
1
1
  import { resolveModelID } from '@kubun/connector';
2
- import { buildCalendarListURL, buildEventsListURL, mapCalendarToEntity, mapEventToEntity, parseGoogleError } from './api.js';
2
+ import { deriveAccountKey } from './account-key.js';
3
+ import { buildCalendarListURL, buildEventsListURL, getCalendarListEntry, getEvent, getEventWithETag, mapCalendarToEntity, mapEventToEntity, parseGoogleError, splitEventSourceID } from './api.js';
4
+ import { GoogleCalendarAPIError } from './errors.js';
3
5
  import { googleCalendarClusters } from './model.js';
4
6
  const CALENDAR_MODEL_ID = resolveModelID(googleCalendarClusters, 'GoogleCalendar');
5
7
  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
- }
17
8
  async function fetchJSON(url, credential, fetchFn, signal) {
18
9
  const response = await fetchFn(url, {
19
10
  headers: {
@@ -78,8 +69,24 @@ async function* fetchEventsForCalendar(params) {
78
69
  export function createGoogleCalendarProvider(config) {
79
70
  const fetchFn = config.fetch ?? globalThis.fetch;
80
71
  const excludeSet = new Set(config.excludeCalendars ?? []);
72
+ const mintFetchHLC = config.mintFetchHLC;
73
+ // Derived once per provider instance — a pure function of (iss,sub), so it is
74
+ // stable across refresh and re-auth. Absent when the credential predates
75
+ // providerAccount capture.
76
+ const accountKey = config.credential.providerAccount != null ? deriveAccountKey(config.credential.providerAccount) : undefined;
77
+ // getCalendarListEntry/getEvent/getEventWithETag (api.ts) throw a plain Error tagged
78
+ // with `.httpStatus` (googleAPIError) — this is the seam fetchEntity/reconcile
79
+ // use to turn a 404 into a `present: false` result instead of a throw.
80
+ function isNotFound(err) {
81
+ return err?.httpStatus === 404;
82
+ }
81
83
  async function* fetchAllEntities(params) {
82
- const timeMin = computeTimeMin(params.boundary);
84
+ // Reuse a window frozen by a prior sync (carried in an expired checkpoint on
85
+ // the 410 resync path, or a re-driven initial) instead of recomputing from
86
+ // `now`; only a genuine first sync computes it fresh.
87
+ const prior = params.checkpoint;
88
+ const windowStart = prior?.windowStart ?? computeTimeMin(params.boundary);
89
+ const timeMin = windowStart;
83
90
  // Phase 1: fetch all calendars (collect all pages first, then yield single batch)
84
91
  const allCalendars = [];
85
92
  let calendarListSyncToken;
@@ -98,9 +105,14 @@ export function createGoogleCalendarProvider(config) {
98
105
  }
99
106
  }
100
107
  // Yield calendar batch (no checkpoint yet — will be on the last event batch)
108
+ const calendarListToken = mintFetchHLC();
101
109
  yield {
102
110
  modelID: CALENDAR_MODEL_ID,
103
- entities: allCalendars.map(mapCalendarToEntity),
111
+ entities: allCalendars.map((c)=>({
112
+ present: true,
113
+ record: mapCalendarToEntity(c, accountKey),
114
+ sourceFetchHLC: calendarListToken
115
+ })),
104
116
  hasMore: true
105
117
  };
106
118
  // Phase 2: fetch events per calendar
@@ -128,19 +140,28 @@ export function createGoogleCalendarProvider(config) {
128
140
  [calendar.id]: {
129
141
  eventsSyncToken: page.nextSyncToken
130
142
  }
131
- }
143
+ },
144
+ ...windowStart != null ? {
145
+ windowStart
146
+ } : {}
132
147
  } : undefined;
133
148
  // Split items: cancelled → deleted, others → entities
134
149
  const activeItems = page.items.filter((e)=>e.status !== 'cancelled');
135
150
  const cancelledItems = page.items.filter((e)=>e.status === 'cancelled');
151
+ const pageToken = mintFetchHLC();
136
152
  yield {
137
153
  modelID: CALENDAR_EVENT_MODEL_ID,
138
- entities: activeItems.map((event)=>mapEventToEntity(event, calendar.id)),
154
+ entities: activeItems.map((event)=>({
155
+ present: true,
156
+ record: mapEventToEntity(event, calendar.id),
157
+ sourceFetchHLC: pageToken
158
+ })),
139
159
  // Match the composite identity mapEventToEntity assigns, so the
140
160
  // tombstone lookup hits the calendar-namespaced document.
141
161
  deleted: cancelledItems.map((e)=>({
142
- sourceService: 'google-calendar',
143
- sourceID: `${calendar.id}/${e.id}`
162
+ present: false,
163
+ sourceID: `${calendar.id}/${e.id}`,
164
+ sourceFetchHLC: pageToken
144
165
  })),
145
166
  hasMore: !(isLastCalendar && isLastPage),
146
167
  checkpoint
@@ -160,13 +181,18 @@ export function createGoogleCalendarProvider(config) {
160
181
  hasMore: false,
161
182
  checkpoint: {
162
183
  calendarListSyncToken,
163
- calendars: calendarTokens
184
+ calendars: calendarTokens,
185
+ ...windowStart != null ? {
186
+ windowStart
187
+ } : {}
164
188
  }
165
189
  };
166
190
  }
167
191
  }
168
192
  async function* fetchIncrementalEntities(params, checkpoint) {
169
- const timeMin = computeTimeMin(params.boundary);
193
+ // Carry the frozen window forward; new calendars are floored by it (not `now`).
194
+ const windowStart = checkpoint.windowStart ?? computeTimeMin(params.boundary);
195
+ const timeMin = windowStart;
170
196
  // Phase 1: fetch calendar list changes using stored syncToken
171
197
  const activeCalendars = [];
172
198
  const deletedCalendarIDs = [];
@@ -189,12 +215,18 @@ export function createGoogleCalendarProvider(config) {
189
215
  }
190
216
  }
191
217
  // Yield calendar batch (active + deletions)
218
+ const calendarListToken = mintFetchHLC();
192
219
  yield {
193
220
  modelID: CALENDAR_MODEL_ID,
194
- entities: activeCalendars.map(mapCalendarToEntity),
221
+ entities: activeCalendars.map((c)=>({
222
+ present: true,
223
+ record: mapCalendarToEntity(c, accountKey),
224
+ sourceFetchHLC: calendarListToken
225
+ })),
195
226
  deleted: deletedCalendarIDs.map((id)=>({
196
- sourceService: 'google-calendar',
197
- sourceID: id
227
+ present: false,
228
+ sourceID: id,
229
+ sourceFetchHLC: calendarListToken
198
230
  })),
199
231
  hasMore: true
200
232
  };
@@ -248,19 +280,28 @@ export function createGoogleCalendarProvider(config) {
248
280
  [calendarID]: {
249
281
  eventsSyncToken: page.nextSyncToken
250
282
  }
251
- }
283
+ },
284
+ ...windowStart != null ? {
285
+ windowStart
286
+ } : {}
252
287
  } : undefined;
253
288
  // Split items: cancelled → deleted, others → entities
254
289
  const activeItems = page.items.filter((e)=>e.status !== 'cancelled');
255
290
  const cancelledItems = page.items.filter((e)=>e.status === 'cancelled');
291
+ const pageToken = mintFetchHLC();
256
292
  yield {
257
293
  modelID: CALENDAR_EVENT_MODEL_ID,
258
- entities: activeItems.map((event)=>mapEventToEntity(event, calendarID)),
294
+ entities: activeItems.map((event)=>({
295
+ present: true,
296
+ record: mapEventToEntity(event, calendarID),
297
+ sourceFetchHLC: pageToken
298
+ })),
259
299
  // Match the composite identity mapEventToEntity assigns, so the
260
300
  // tombstone lookup hits the calendar-namespaced document.
261
301
  deleted: cancelledItems.map((e)=>({
262
- sourceService: 'google-calendar',
263
- sourceID: `${calendarID}/${e.id}`
302
+ present: false,
303
+ sourceID: `${calendarID}/${e.id}`,
304
+ sourceFetchHLC: pageToken
264
305
  })),
265
306
  hasMore: !(isLastCalendar && isLastPage),
266
307
  checkpoint: newCheckpoint
@@ -279,16 +320,109 @@ export function createGoogleCalendarProvider(config) {
279
320
  hasMore: false,
280
321
  checkpoint: {
281
322
  calendarListSyncToken: newCalendarListSyncToken,
282
- calendars: updatedCalendarTokens
323
+ calendars: updatedCalendarTokens,
324
+ ...windowStart != null ? {
325
+ windowStart
326
+ } : {}
283
327
  }
284
328
  };
285
329
  }
286
330
  }
331
+ // Targeted single-entity fetch (anti-entropy repair verification). Mints its
332
+ // own fetch-order token at request start, independent of any batch token
333
+ // (see ServerProviderFactory.mintFetchHLC's doc). `model` distinguishes the
334
+ // account-level GoogleCalendar resource from a GoogleCalendarEvent — only
335
+ // the latter's sourceID is the composite calendarID/eventID.
336
+ async function fetchEntity(model, sourceID) {
337
+ const sourceFetchHLC = mintFetchHLC();
338
+ try {
339
+ if (model === CALENDAR_MODEL_ID) {
340
+ const calendar = await getCalendarListEntry({
341
+ accessToken: config.credential.accessToken,
342
+ calendarID: sourceID,
343
+ fetch: fetchFn
344
+ });
345
+ return {
346
+ present: true,
347
+ record: mapCalendarToEntity(calendar, accountKey),
348
+ sourceFetchHLC
349
+ };
350
+ }
351
+ const { calendarID, eventID } = splitEventSourceID(sourceID);
352
+ const record = await getEvent({
353
+ accessToken: config.credential.accessToken,
354
+ calendarID,
355
+ eventID,
356
+ fetch: fetchFn
357
+ });
358
+ return {
359
+ present: true,
360
+ record,
361
+ sourceFetchHLC
362
+ };
363
+ } catch (err) {
364
+ if (isNotFound(err)) {
365
+ return {
366
+ present: false,
367
+ sourceID,
368
+ sourceFetchHLC
369
+ };
370
+ }
371
+ throw err;
372
+ }
373
+ }
374
+ // Provider-authoritative re-read for write-back recovery. Unlike
375
+ // fetchEntity, the present:true branch also surfaces a providerETag when the
376
+ // provider reports one — the current revision an update's If-Match can
377
+ // condition on.
378
+ async function reconcile(model, sourceID) {
379
+ const sourceFetchHLC = mintFetchHLC();
380
+ try {
381
+ if (model === CALENDAR_MODEL_ID) {
382
+ const calendar = await getCalendarListEntry({
383
+ accessToken: config.credential.accessToken,
384
+ calendarID: sourceID,
385
+ fetch: fetchFn
386
+ });
387
+ return {
388
+ present: true,
389
+ record: mapCalendarToEntity(calendar, accountKey),
390
+ sourceFetchHLC
391
+ };
392
+ }
393
+ const { calendarID, eventID } = splitEventSourceID(sourceID);
394
+ const { record, etag } = await getEventWithETag({
395
+ accessToken: config.credential.accessToken,
396
+ calendarID,
397
+ eventID,
398
+ fetch: fetchFn
399
+ });
400
+ return {
401
+ present: true,
402
+ record,
403
+ sourceFetchHLC,
404
+ ...etag != null ? {
405
+ providerETag: etag
406
+ } : {}
407
+ };
408
+ } catch (err) {
409
+ if (isNotFound(err)) {
410
+ return {
411
+ present: false,
412
+ sourceID,
413
+ sourceFetchHLC
414
+ };
415
+ }
416
+ throw err;
417
+ }
418
+ }
287
419
  return {
288
420
  supportedStrategies: [
289
421
  'on-demand'
290
422
  ],
291
423
  fetchAll: fetchAllEntities,
424
+ fetchEntity,
425
+ reconcile,
292
426
  async *fetchChanges (params) {
293
427
  const checkpoint = params.checkpoint;
294
428
  if (checkpoint == null) {
@@ -0,0 +1,11 @@
1
+ import type { ActionTargetResolver } from '@kubun/connector';
2
+ /**
3
+ * The Calendar target resolver (see `ActionTargetResolver`): a write's target
4
+ * is the calendar it lands in.
5
+ * - A create targets the `calendarID` of its input, required.
6
+ * - An update targets the calendar of its event (the `calendarID/eventID`
7
+ * sourceID); a `calendarID` in its input must name that same calendar.
8
+ * - The `'primary'` alias is resolved to the account's synced primary
9
+ * calendar, and the input carries the resolved id from then on.
10
+ */
11
+ export declare const resolveCalendarTarget: ActionTargetResolver;
package/lib/target.js ADDED
@@ -0,0 +1,68 @@
1
+ import { deriveAccountKey } from './account-key.js';
2
+ import { splitEventSourceID } from './api.js';
3
+ import { GoogleCalendarError } from './errors.js';
4
+ import { googleCalendarDefinition } from './model.js';
5
+ function calendarError(code, message) {
6
+ return new GoogleCalendarError(`${code}: ${message}`, {
7
+ code,
8
+ kind: 'rejected'
9
+ });
10
+ }
11
+ // The account's primary calendar, as synced to this device: the calendar-list
12
+ // entry Google flags primary, under this account. Never a provider call —
13
+ // before the calendars are synced there is nothing to resolve it to.
14
+ async function syncedPrimaryCalendar(storage, providerAccount) {
15
+ const accountKey = deriveAccountKey(providerAccount);
16
+ const primary = (await storage.documents(googleCalendarDefinition.name)).filter((calendar)=>calendar.primary === true && calendar.accountKey === accountKey);
17
+ const [only] = primary;
18
+ if (primary.length !== 1 || typeof only?.sourceID !== 'string') {
19
+ throw calendarError('GOOGLE_CALENDARS_NOT_SYNCED', primary.length === 0 ? 'the primary calendar is not known on this device yet; sync calendars first' : 'more than one synced calendar is flagged primary for this account; sync calendars again');
20
+ }
21
+ return only.sourceID;
22
+ }
23
+ /**
24
+ * The Calendar target resolver (see `ActionTargetResolver`): a write's target
25
+ * is the calendar it lands in.
26
+ * - A create targets the `calendarID` of its input, required.
27
+ * - An update targets the calendar of its event (the `calendarID/eventID`
28
+ * sourceID); a `calendarID` in its input must name that same calendar.
29
+ * - The `'primary'` alias is resolved to the account's synced primary
30
+ * calendar, and the input carries the resolved id from then on.
31
+ */ export const resolveCalendarTarget = async ({ op, input, sourceID, providerAccount, storage })=>{
32
+ const named = input.calendarID;
33
+ const resolveNamed = async ()=>{
34
+ if (named == null) return undefined;
35
+ if (typeof named !== 'string' || named === '') {
36
+ throw calendarError('GOOGLE_CALENDAR_ID_REQUIRED', 'calendarID must be a calendar id');
37
+ }
38
+ return named === 'primary' ? await syncedPrimaryCalendar(storage, providerAccount) : named;
39
+ };
40
+ if (op === 'create') {
41
+ const calendarID = await resolveNamed();
42
+ if (calendarID == null) {
43
+ throw calendarError('GOOGLE_CALENDAR_ID_REQUIRED', 'a new event needs a calendarID');
44
+ }
45
+ return {
46
+ target: calendarID,
47
+ input: {
48
+ ...input,
49
+ calendarID
50
+ }
51
+ };
52
+ }
53
+ if (sourceID == null) {
54
+ throw calendarError('GOOGLE_CALENDAR_ID_REQUIRED', 'an event update needs its sourceID');
55
+ }
56
+ const eventCalendarID = splitEventSourceID(sourceID).calendarID;
57
+ const calendarID = await resolveNamed();
58
+ if (calendarID != null && calendarID !== eventCalendarID) {
59
+ throw calendarError('GOOGLE_CALENDAR_ID_MISMATCH', `calendarID does not name the calendar of the event "${sourceID}"`);
60
+ }
61
+ return {
62
+ target: eventCalendarID,
63
+ input: calendarID != null ? {
64
+ ...input,
65
+ calendarID
66
+ } : input
67
+ };
68
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/connector-google-calendar",
3
- "version": "0.17.0",
3
+ "version": "0.17.2",
4
4
  "keywords": [],
5
5
  "license": "see LICENSE.md",
6
6
  "sideEffects": false,
@@ -15,12 +15,18 @@
15
15
  "LICENSE.md"
16
16
  ],
17
17
  "dependencies": {
18
- "@kubun/connector": "^0.17.0",
19
- "@kubun/credential-types": "^0.17.0",
18
+ "@kubun/connector": "^0.17.2",
19
+ "@kubun/credential-types": "^0.17.2",
20
+ "@kubun/hlc": "^0.17.0",
21
+ "@kubun/id": "^0.17.0",
20
22
  "@kubun/models": "^0.17.0",
21
- "@kubun/protocol": "^0.17.0",
23
+ "@kubun/protocol": "^0.17.1",
22
24
  "@kubun/provider-google": "^0.17.0",
23
- "@kubun/scalars": "^0.17.0"
25
+ "@kubun/scalars": "^0.17.0",
26
+ "@sozai/codec": "^0.4.0"
27
+ },
28
+ "devDependencies": {
29
+ "@sozai/schema": "^0.1.2"
24
30
  },
25
31
  "publishConfig": {
26
32
  "access": "public"