@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.
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Non-reversible, deterministic account key projected to the graph in place of
3
+ * the raw provider identity. A pure function of (iss, sub) — no salt — so it
4
+ * survives credential deletion/re-auth so it stays stable across reconcile.
5
+ * This is a consistency label, not provenance.
6
+ */
7
+ export declare function deriveAccountKey(account: {
8
+ iss: string;
9
+ sub: string;
10
+ }): string;
@@ -0,0 +1,19 @@
1
+ import { digestJSON } from '@kubun/id';
2
+ import { toB64U } from '@sozai/codec';
3
+ /**
4
+ * Non-reversible, deterministic account key projected to the graph in place of
5
+ * the raw provider identity. A pure function of (iss, sub) — no salt — so it
6
+ * survives credential deletion/re-auth so it stays stable across reconcile.
7
+ * This is a consistency label, not provenance.
8
+ */ export function deriveAccountKey(account) {
9
+ // The extractor (plugin-connector's extractProviderAccount) accepts both
10
+ // 'https://accounts.google.com' and bare 'accounts.google.com' as valid
11
+ // Google iss claims. The key must be stable across whichever spelling a
12
+ // given token carries, so canonicalize before hashing.
13
+ const canonicalIss = account.iss === 'accounts.google.com' ? 'https://accounts.google.com' : account.iss;
14
+ return toB64U(digestJSON({
15
+ v: 'gcal-account',
16
+ iss: canonicalIss,
17
+ sub: account.sub
18
+ }));
19
+ }
package/lib/actions.d.ts CHANGED
@@ -5,5 +5,6 @@ export type GoogleCalendarActionHandlerConfig = {
5
5
  scopes: Array<string>;
6
6
  };
7
7
  fetch?: typeof globalThis.fetch;
8
+ mintFetchHLC: () => string;
8
9
  };
9
10
  export declare function createGoogleCalendarActionHandler(config: GoogleCalendarActionHandlerConfig): ActionHandler;
package/lib/actions.js CHANGED
@@ -1,59 +1,111 @@
1
- import { createEvent, getCalendar, updateEvent } from './api.js';
1
+ import { createEvent, updateEvent } from './api.js';
2
+ import { GoogleCalendarError } from './errors.js';
3
+ import { deriveEventID } from './event-id.js';
4
+ /**
5
+ * The params of a sparse PATCH: only what the write changes, never the rest
6
+ * of the (possibly stale) local document. The one exception is how a changed
7
+ * start/end is written: it keeps the event's all-day form and time zone from
8
+ * the target document, and a changed time zone or all-day flag rewrites the
9
+ * start and end it applies to.
10
+ */ function sparsePatchParams(params, existing) {
11
+ const patch = {
12
+ ...params
13
+ };
14
+ // An all-day flip carries the event's current start/end over in the new
15
+ // form. Turning a timed event whose end falls on its start day all-day
16
+ // gives start.date == end.date, which Google rejects (400, an exclusive end
17
+ // must be later): the write dead-letters rather than guessing an end.
18
+ const reshapesTimes = 'timeZone' in params || 'allDay' in params;
19
+ for (const field of [
20
+ 'startAt',
21
+ 'endAt'
22
+ ]){
23
+ if (patch[field] == null && reshapesTimes && existing?.[field] != null) {
24
+ patch[field] = existing[field];
25
+ }
26
+ }
27
+ const writesTimes = patch.startAt != null || patch.endAt != null;
28
+ if (writesTimes) {
29
+ if (!('allDay' in patch) && existing?.allDay != null) patch.allDay = existing.allDay;
30
+ if (!('timeZone' in patch) && existing?.timeZone != null) patch.timeZone = existing.timeZone;
31
+ }
32
+ return patch;
33
+ }
2
34
  export function createGoogleCalendarActionHandler(config) {
35
+ const mintActionFetchHLC = config.mintFetchHLC;
3
36
  return {
4
- async create (_model, params) {
37
+ async create (_model, params, context) {
5
38
  const calendarID = params.calendarID;
6
39
  if (calendarID == null) {
7
40
  throw new Error('calendarID is required for creating calendar events');
8
41
  }
9
- // 'primary' is a per-account alias; sync sees the real id. Resolve it once
10
- // on create so the composite sourceID matches the synced event's identity.
11
- const resolvedCalendarID = calendarID === 'primary' ? (await getCalendar({
12
- accessToken: config.credential.accessToken,
13
- calendarID: 'primary',
14
- fetch: config.fetch
15
- })).id : calendarID;
16
- return createEvent({
42
+ // The action layer resolves the 'primary' alias from synced data before
43
+ // the operation is claimed (resolveCalendarTarget); the handler writes
44
+ // only to a resolved calendar and never resolves the alias itself.
45
+ if (calendarID === 'primary') {
46
+ throw new GoogleCalendarError("the 'primary' calendar alias must be resolved before the write; name the calendar id", {
47
+ kind: 'rejected'
48
+ });
49
+ }
50
+ const resolvedCalendarID = calendarID;
51
+ // The provider id is derived from the operation's deterministic event
52
+ // id, so every attempt of the operation inserts under the same id and a
53
+ // re-issue after a lost response hits a 409 instead of a duplicate.
54
+ const record = await createEvent({
17
55
  accessToken: config.credential.accessToken,
18
56
  calendarID: resolvedCalendarID,
19
57
  params,
58
+ ...context != null ? {
59
+ eventID: deriveEventID(context.deterministicEventID)
60
+ } : {},
20
61
  fetch: config.fetch
21
62
  });
63
+ return {
64
+ present: true,
65
+ record,
66
+ sourceFetchHLC: mintActionFetchHLC()
67
+ };
22
68
  },
23
- async update (_model, sourceID, params) {
24
- // sourceID is the composite calendarID/eventID. On a lookup hit the merged
25
- // document supplies eventID + calendarID directly; on a miss recover both
26
- // from the composite. An eventID never contains '/', a calendarID may, so
27
- // split on the last '/'.
28
- const mergedEventID = params.eventID;
29
- const mergedCalendarID = params.calendarID;
69
+ async update (_model, sourceID, params, existing, context) {
70
+ // sourceID is the composite calendarID/eventID. The target document
71
+ // (`existing`) names the event; without it, recover both ids from the
72
+ // composite — an eventID never contains '/', a calendarID may, so split
73
+ // on the last '/'.
74
+ const knownEventID = existing?.eventID ?? params.eventID;
75
+ const knownCalendarID = existing?.calendarID ?? params.calendarID;
30
76
  let eventID;
31
77
  let calendarID;
32
- if (mergedEventID != null) {
33
- if (mergedCalendarID == null) {
78
+ if (knownEventID != null) {
79
+ if (knownCalendarID == null) {
34
80
  throw new Error('calendarID is required for updating calendar events');
35
81
  }
36
- eventID = mergedEventID;
37
- calendarID = mergedCalendarID;
82
+ eventID = knownEventID;
83
+ calendarID = knownCalendarID;
38
84
  } else {
39
85
  const slash = sourceID.lastIndexOf('/');
40
86
  if (slash <= 0 || slash === sourceID.length - 1) {
41
87
  throw new Error(`Cannot recover eventID from sourceID "${sourceID}"`);
42
88
  }
43
89
  const recoveredCalendarID = sourceID.slice(0, slash);
44
- if (mergedCalendarID != null && mergedCalendarID !== recoveredCalendarID) {
90
+ if (knownCalendarID != null && knownCalendarID !== recoveredCalendarID) {
45
91
  throw new Error('calendarID does not match the event sourceID');
46
92
  }
47
93
  eventID = sourceID.slice(slash + 1);
48
94
  calendarID = recoveredCalendarID;
49
95
  }
50
- return updateEvent({
96
+ const record = await updateEvent({
51
97
  accessToken: config.credential.accessToken,
52
98
  calendarID,
53
99
  eventID,
54
- params,
100
+ params: sparsePatchParams(params, existing),
101
+ etag: context?.providerETag,
55
102
  fetch: config.fetch
56
103
  });
104
+ return {
105
+ present: true,
106
+ record,
107
+ sourceFetchHLC: mintActionFetchHLC()
108
+ };
57
109
  }
58
110
  };
59
111
  }
package/lib/api.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { EntityRecord } from '@kubun/connector';
1
+ import { type EntityRecord } from '@kubun/connector';
2
+ export { type ConnectorErrorKind, classifyHTTPStatus } from '@kubun/connector';
2
3
  export declare function parseGoogleError(response: Response): Promise<string>;
3
4
  export type GoogleCalendarResource = {
4
5
  id: string;
@@ -23,6 +24,7 @@ export type GoogleEventDateTime = {
23
24
  };
24
25
  export type GoogleEventResource = {
25
26
  id: string;
27
+ etag?: string;
26
28
  summary?: string;
27
29
  description?: string;
28
30
  location?: string;
@@ -47,26 +49,42 @@ export declare function buildEventsListURL(params: {
47
49
  pageToken?: string;
48
50
  timeMin?: string;
49
51
  }): string;
50
- export declare function mapCalendarToEntity(calendar: GoogleCalendarResource): EntityRecord;
52
+ export declare function mapCalendarToEntity(calendar: GoogleCalendarResource, accountKey?: string): EntityRecord;
51
53
  export declare function mapEventToEntity(event: GoogleEventResource, calendarID: string): EntityRecord;
52
54
  export declare function buildEventFromParams(params: Record<string, unknown>, options?: {
53
55
  create?: boolean;
56
+ eventID?: string;
54
57
  }): Record<string, unknown>;
55
- export declare function getCalendar(config: {
58
+ export declare function getCalendarListEntry(config: {
56
59
  accessToken: string;
57
60
  calendarID: string;
58
61
  fetch?: typeof globalThis.fetch;
59
62
  }): Promise<GoogleCalendarResource>;
63
+ export declare function splitEventSourceID(sourceID: string): {
64
+ calendarID: string;
65
+ eventID: string;
66
+ };
60
67
  export declare function getEvent(config: {
61
68
  accessToken: string;
62
69
  calendarID: string;
63
70
  eventID: string;
64
71
  fetch?: typeof globalThis.fetch;
65
72
  }): Promise<EntityRecord>;
73
+ export declare function getEventWithETag(config: {
74
+ accessToken: string;
75
+ calendarID: string;
76
+ eventID: string;
77
+ fetch?: typeof globalThis.fetch;
78
+ }): Promise<{
79
+ record: EntityRecord;
80
+ etag?: string;
81
+ }>;
66
82
  export declare function createEvent(config: {
67
83
  accessToken: string;
68
84
  calendarID: string;
69
85
  params: Record<string, unknown>;
86
+ /** The provider event id to insert under (see `deriveEventID`); takes precedence over `params.id`. */
87
+ eventID?: string;
70
88
  fetch?: typeof globalThis.fetch;
71
89
  }): Promise<EntityRecord>;
72
90
  export declare function updateEvent(config: {
@@ -74,5 +92,13 @@ export declare function updateEvent(config: {
74
92
  calendarID: string;
75
93
  eventID: string;
76
94
  params: Record<string, unknown>;
95
+ /**
96
+ * The event's etag as last fetched, sent as If-Match: a change at Google
97
+ * since then fails the PATCH with a 412 (outcome `unknown`, resolved by
98
+ * recovery's reconcile read) instead of being overwritten. It is never
99
+ * re-read right before the write — that would guard nothing. Absent, the
100
+ * PATCH is unconditional.
101
+ */
102
+ etag?: string | null;
77
103
  fetch?: typeof globalThis.fetch;
78
104
  }): Promise<EntityRecord>;
package/lib/api.js CHANGED
@@ -1,3 +1,7 @@
1
+ import { connectorHTTPError } from '@kubun/connector';
2
+ import { GoogleCalendarEventConflict } from './errors.js';
3
+ import { eventHoldsIntent } from './intent.js';
4
+ export { classifyHTTPStatus } from '@kubun/connector';
1
5
  const CALENDAR_BASE_URL = 'https://www.googleapis.com/calendar/v3';
2
6
  export async function parseGoogleError(response) {
3
7
  try {
@@ -7,6 +11,23 @@ export async function parseGoogleError(response) {
7
11
  return `${response.status} ${response.statusText}`;
8
12
  }
9
13
  }
14
+ /**
15
+ * Build the error thrown by an action's provider call, tagged with the HTTP
16
+ * status and its outcome classification so the caller can distinguish a
17
+ * definite rejection from an indeterminate failure.
18
+ */ function googleAPIError(status, detail) {
19
+ return connectorHTTPError(`Google Calendar API error: ${detail}`, status);
20
+ }
21
+ /**
22
+ * A create that collided with an existing event it could not confirm as its
23
+ * own. Outcome-unknown, not a rejection: the event may be this create's own
24
+ * earlier effect, edited since. `code: 'CONFLICT'` and `sourceID` let
25
+ * write-back recovery reconcile against the event.
26
+ */ function eventConflict(sourceID, cause) {
27
+ return new GoogleCalendarEventConflict(sourceID, cause != null ? {
28
+ cause
29
+ } : undefined);
30
+ }
10
31
  export function buildCalendarListURL(params) {
11
32
  const url = new URL(`${CALENDAR_BASE_URL}/users/me/calendarList`);
12
33
  // Google rejects `showHidden=false` (and `showDeleted=false`) alongside a
@@ -37,7 +58,7 @@ export function buildEventsListURL(params) {
37
58
  }
38
59
  return url.toString();
39
60
  }
40
- export function mapCalendarToEntity(calendar) {
61
+ export function mapCalendarToEntity(calendar, accountKey) {
41
62
  return {
42
63
  sourceService: 'google-calendar',
43
64
  sourceID: calendar.id,
@@ -46,7 +67,10 @@ export function mapCalendarToEntity(calendar) {
46
67
  description: calendar.description,
47
68
  timeZone: calendar.timeZone,
48
69
  primary: calendar.primary ?? false,
49
- backgroundColor: calendar.backgroundColor
70
+ backgroundColor: calendar.backgroundColor,
71
+ ...accountKey != null ? {
72
+ accountKey
73
+ } : {}
50
74
  };
51
75
  }
52
76
  // Google gives all-day events a bare calendar date (`YYYY-MM-DD`); the model's
@@ -81,17 +105,19 @@ export function mapEventToEntity(event, calendarID) {
81
105
  calendar: {
82
106
  sourceService: 'google-calendar',
83
107
  sourceID: calendarID
84
- }
108
+ },
109
+ etag: event.etag
85
110
  };
86
111
  }
87
112
  export function buildEventFromParams(params, options) {
88
113
  const event = {};
89
114
  const allDay = params.allDay === true;
90
- // A caller-supplied id makes a create idempotent (Google rejects a duplicate
115
+ // A caller-chosen id makes a create idempotent (Google rejects a duplicate
91
116
  // with 409). Only insert accepts an id; PATCH must never carry one, so gate it
92
117
  // to the create path — the builder is shared with updateEvent.
93
- if (options?.create === true && params.id != null) {
94
- event.id = params.id;
118
+ const eventID = options?.eventID ?? params.id;
119
+ if (options?.create === true && eventID != null) {
120
+ event.id = eventID;
95
121
  }
96
122
  if (params.title != null) {
97
123
  event.summary = params.title;
@@ -127,9 +153,13 @@ export function buildEventFromParams(params, options) {
127
153
  }
128
154
  return event;
129
155
  }
130
- export async function getCalendar(config) {
156
+ // One calendar as the account's calendar list holds it (calendarList.get) —
157
+ // the same resource sync maps, with the per-account `primary` flag and
158
+ // colour. calendars.get carries neither, so a re-pull from it would clear
159
+ // `primary` and break resolving the 'primary' alias.
160
+ export async function getCalendarListEntry(config) {
131
161
  const fetchFn = config.fetch ?? globalThis.fetch;
132
- const url = `${CALENDAR_BASE_URL}/calendars/${encodeURIComponent(config.calendarID)}`;
162
+ const url = `${CALENDAR_BASE_URL}/users/me/calendarList/${encodeURIComponent(config.calendarID)}`;
133
163
  const response = await fetchFn(url, {
134
164
  headers: {
135
165
  Authorization: `Bearer ${config.accessToken}`
@@ -137,11 +167,23 @@ export async function getCalendar(config) {
137
167
  });
138
168
  if (!response.ok) {
139
169
  const errorMessage = await parseGoogleError(response);
140
- throw new Error(`Google Calendar API error: ${errorMessage}`);
170
+ throw googleAPIError(response.status, errorMessage);
141
171
  }
142
172
  return await response.json();
143
173
  }
144
- export async function getEvent(config) {
174
+ // A composite calendarID/eventID sourceID splits on the LAST '/': an eventID
175
+ // never contains one, but a calendarID (email-like) may.
176
+ export function splitEventSourceID(sourceID) {
177
+ const slash = sourceID.lastIndexOf('/');
178
+ if (slash <= 0 || slash === sourceID.length - 1) {
179
+ throw new Error(`Cannot recover eventID from sourceID "${sourceID}"`);
180
+ }
181
+ return {
182
+ calendarID: sourceID.slice(0, slash),
183
+ eventID: sourceID.slice(slash + 1)
184
+ };
185
+ }
186
+ async function fetchEventResource(config) {
145
187
  const fetchFn = config.fetch ?? globalThis.fetch;
146
188
  const url = `${CALENDAR_BASE_URL}/calendars/${encodeURIComponent(config.calendarID)}/events/${encodeURIComponent(config.eventID)}`;
147
189
  const response = await fetchFn(url, {
@@ -151,16 +193,31 @@ export async function getEvent(config) {
151
193
  });
152
194
  if (!response.ok) {
153
195
  const errorMessage = await parseGoogleError(response);
154
- throw new Error(`Google Calendar API error: ${errorMessage}`);
196
+ throw googleAPIError(response.status, errorMessage);
155
197
  }
156
- const data = await response.json();
198
+ return await response.json();
199
+ }
200
+ export async function getEvent(config) {
201
+ const data = await fetchEventResource(config);
157
202
  return mapEventToEntity(data, config.calendarID);
158
203
  }
204
+ // Same as getEvent, but also surfaces the provider's etag/revision — used by
205
+ // write-back recovery's reconcile read, which reports a providerETag
206
+ // alongside the record.
207
+ export async function getEventWithETag(config) {
208
+ const data = await fetchEventResource(config);
209
+ return {
210
+ record: mapEventToEntity(data, config.calendarID),
211
+ etag: data.etag
212
+ };
213
+ }
159
214
  export async function createEvent(config) {
160
215
  const fetchFn = config.fetch ?? globalThis.fetch;
161
216
  const event = buildEventFromParams(config.params, {
162
- create: true
217
+ create: true,
218
+ eventID: config.eventID
163
219
  });
220
+ const eventID = event.id;
164
221
  const url = `${CALENDAR_BASE_URL}/calendars/${encodeURIComponent(config.calendarID)}/events`;
165
222
  const response = await fetchFn(url, {
166
223
  method: 'POST',
@@ -171,19 +228,34 @@ export async function createEvent(config) {
171
228
  body: JSON.stringify(event)
172
229
  });
173
230
  if (!response.ok) {
174
- // A 409 on a caller-supplied id means the event already exists — the create
175
- // is already committed, so a retry after a lost response is idempotent: fetch
176
- // and return the existing event instead of failing.
177
- if (response.status === 409 && config.params.id != null) {
178
- return getEvent({
179
- accessToken: config.accessToken,
180
- calendarID: config.calendarID,
181
- eventID: config.params.id,
182
- fetch: config.fetch
183
- });
231
+ // A 409 on a caller-chosen id means an event already exists under it. It
232
+ // is this create's own earlier effect (a retry after a lost response) only
233
+ // if it holds what this create writes; anything else is a conflict naming
234
+ // the existing event, for recovery to reconcile — never a success.
235
+ if (response.status === 409 && eventID != null) {
236
+ const sourceID = `${config.calendarID}/${eventID}`;
237
+ let existing;
238
+ try {
239
+ existing = await getEvent({
240
+ accessToken: config.accessToken,
241
+ calendarID: config.calendarID,
242
+ eventID,
243
+ fetch: config.fetch
244
+ });
245
+ } catch (err) {
246
+ throw eventConflict(sourceID, err);
247
+ }
248
+ if (!eventHoldsIntent({
249
+ op: 'create',
250
+ intent: config.params,
251
+ record: existing
252
+ })) {
253
+ throw eventConflict(sourceID);
254
+ }
255
+ return existing;
184
256
  }
185
257
  const errorMessage = await parseGoogleError(response);
186
- throw new Error(`Google Calendar API error: ${errorMessage}`);
258
+ throw googleAPIError(response.status, errorMessage);
187
259
  }
188
260
  const data = await response.json();
189
261
  return mapEventToEntity(data, config.calendarID);
@@ -196,13 +268,16 @@ export async function updateEvent(config) {
196
268
  method: 'PATCH',
197
269
  headers: {
198
270
  Authorization: `Bearer ${config.accessToken}`,
199
- 'Content-Type': 'application/json'
271
+ 'Content-Type': 'application/json',
272
+ ...config.etag != null ? {
273
+ 'If-Match': config.etag
274
+ } : {}
200
275
  },
201
276
  body: JSON.stringify(event)
202
277
  });
203
278
  if (!response.ok) {
204
279
  const errorMessage = await parseGoogleError(response);
205
- throw new Error(`Google Calendar API error: ${errorMessage}`);
280
+ throw googleAPIError(response.status, errorMessage);
206
281
  }
207
282
  const data = await response.json();
208
283
  return mapEventToEntity(data, config.calendarID);
package/lib/connector.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { createGoogleCalendarActionHandler } from './actions.js';
2
+ import { canonicalizeEventIntent } from './intent.js';
2
3
  import { googleCalendarClusters } from './model.js';
4
+ import { predictCreatedEventSourceID } from './predict.js';
3
5
  import { createGoogleCalendarProvider } from './provider.js';
6
+ import { resolveCalendarTarget } from './target.js';
4
7
  export function createGoogleCalendarConnector(options) {
5
8
  return {
6
9
  name: 'google-calendar',
@@ -42,7 +45,12 @@ export function createGoogleCalendarConnector(options) {
42
45
  }
43
46
  }
44
47
  },
45
- requiresCredential: true
48
+ requiresCredential: true,
49
+ // The provider event id is derived from the operation's deterministic
50
+ // event id (see deriveEventID), so a reissue inserts under the same id
51
+ // as the original attempt: a create that already landed answers 409
52
+ // instead of inserting a duplicate — safe to reissue.
53
+ recoveryClass: 'reissue-safe'
46
54
  },
47
55
  {
48
56
  name: 'update',
@@ -76,13 +84,29 @@ export function createGoogleCalendarConnector(options) {
76
84
  }
77
85
  }
78
86
  },
79
- requiresCredential: true
87
+ requiresCredential: true,
88
+ // A sparse PATCH conditioned (If-Match) on the etag of the document
89
+ // the edit was made against, pinned on the operation at claim. A
90
+ // reissue after an unknown outcome would meet its own earlier write's
91
+ // new etag as a 412, and an unconditional one could clobber an
92
+ // intervening edit, so recovery reconciles (reads current provider
93
+ // state) rather than reissues.
94
+ recoveryClass: 'reconcile-only',
95
+ providerETagField: 'etag',
96
+ // An edit is only ever written against the version it was made on: an
97
+ // event with no stored etag (not synced yet) is refused before the
98
+ // claim rather than written unconditionally over a provider edit.
99
+ providerETagRequired: true
80
100
  }
81
101
  ],
102
+ canonicalizeIntent: canonicalizeEventIntent,
103
+ resolveActionTarget: resolveCalendarTarget,
104
+ predictCreatedSourceID: predictCreatedEventSourceID,
82
105
  actionHandler: (config)=>{
83
106
  return createGoogleCalendarActionHandler({
84
107
  credential: config.credential,
85
- fetch: options?.fetch
108
+ fetch: options?.fetch,
109
+ mintFetchHLC: config.mintFetchHLC
86
110
  });
87
111
  },
88
112
  auth: {
@@ -98,7 +122,8 @@ export function createGoogleCalendarConnector(options) {
98
122
  return createGoogleCalendarProvider({
99
123
  credential: config.credential,
100
124
  boundary: config.boundary,
101
- fetch: options?.fetch
125
+ fetch: options?.fetch,
126
+ mintFetchHLC: config.mintFetchHLC
102
127
  });
103
128
  }
104
129
  };
@@ -0,0 +1,32 @@
1
+ import { ConnectorError, type ConnectorErrorOptions } from '@kubun/connector';
2
+ /** A machine-readable failure code fixed for a given Google Calendar failure. */
3
+ export type GoogleCalendarErrorCode = 'GOOGLE_CALENDARS_NOT_SYNCED' | 'GOOGLE_CALENDAR_ID_REQUIRED' | 'GOOGLE_CALENDAR_ID_MISMATCH' | 'CONFLICT';
4
+ export type GoogleCalendarErrorOptions = ConnectorErrorOptions & {
5
+ code?: GoogleCalendarErrorCode;
6
+ };
7
+ /** A Google Calendar connector failure, classified the way `ConnectorError` is. */
8
+ export declare class GoogleCalendarError extends ConnectorError {
9
+ constructor(message: string, options?: GoogleCalendarErrorOptions);
10
+ get code(): GoogleCalendarErrorCode | undefined;
11
+ }
12
+ /**
13
+ * A create that collided with an existing event it could not confirm as its
14
+ * own. Outcome-unknown, not a rejection: the event may be this create's own
15
+ * earlier effect, edited since. `sourceID` lets write-back recovery
16
+ * reconcile against the event.
17
+ */
18
+ export declare class GoogleCalendarEventConflict extends GoogleCalendarError {
19
+ #private;
20
+ constructor(sourceID: string, options?: ErrorOptions);
21
+ get sourceID(): string;
22
+ }
23
+ /**
24
+ * A raw provider HTTP failure from a batch/list round trip (`fetchJSON` in
25
+ * `provider.ts`), carrying the response status so `fetchChanges` can detect
26
+ * an expired sync token (410) and fall back to a full resync.
27
+ */
28
+ export declare class GoogleCalendarAPIError extends Error {
29
+ #private;
30
+ constructor(status: number, detail: string);
31
+ get status(): number;
32
+ }
package/lib/errors.js ADDED
@@ -0,0 +1,46 @@
1
+ import { ConnectorError } from '@kubun/connector';
2
+ /** A Google Calendar connector failure, classified the way `ConnectorError` is. */ export class GoogleCalendarError extends ConnectorError {
3
+ constructor(message, options = {}){
4
+ super(message, options);
5
+ this.name = 'GoogleCalendarError';
6
+ }
7
+ get code() {
8
+ return super.code;
9
+ }
10
+ }
11
+ /**
12
+ * A create that collided with an existing event it could not confirm as its
13
+ * own. Outcome-unknown, not a rejection: the event may be this create's own
14
+ * earlier effect, edited since. `sourceID` lets write-back recovery
15
+ * reconcile against the event.
16
+ */ export class GoogleCalendarEventConflict extends GoogleCalendarError {
17
+ #sourceID;
18
+ constructor(sourceID, options){
19
+ super(`Google Calendar API error: an event already exists as "${sourceID}" and does not hold this create`, {
20
+ ...options,
21
+ code: 'CONFLICT',
22
+ httpStatus: 409,
23
+ kind: 'unknown'
24
+ });
25
+ this.name = 'GoogleCalendarEventConflict';
26
+ this.#sourceID = sourceID;
27
+ }
28
+ get sourceID() {
29
+ return this.#sourceID;
30
+ }
31
+ }
32
+ /**
33
+ * A raw provider HTTP failure from a batch/list round trip (`fetchJSON` in
34
+ * `provider.ts`), carrying the response status so `fetchChanges` can detect
35
+ * an expired sync token (410) and fall back to a full resync.
36
+ */ export class GoogleCalendarAPIError extends Error {
37
+ #status;
38
+ constructor(status, detail){
39
+ super(`Google Calendar API error: ${detail}`);
40
+ this.name = 'GoogleCalendarAPIError';
41
+ this.#status = status;
42
+ }
43
+ get status() {
44
+ return this.#status;
45
+ }
46
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The Google Calendar event id a create inserts under, derived from the
3
+ * operation's deterministic event id: the lowercase base32hex encoding (no
4
+ * padding) of a 32-byte digest of it, 52 characters — inside Google's allowed
5
+ * alphabet (`a-v`, `0-9`) and length (5 to 1024).
6
+ *
7
+ * Every attempt of the same operation derives the same id, so a create that is
8
+ * re-issued after its response was lost collides with its own earlier effect
9
+ * (a 409) instead of inserting a duplicate event. The mapping is one-way and
10
+ * versioned: changing it would re-key every create not yet confirmed.
11
+ */
12
+ export declare function deriveEventID(deterministicEventID: string): string;
@@ -0,0 +1,35 @@
1
+ import { digestJSON } from '@kubun/id';
2
+ // Google Calendar event ids use the base32hex alphabet (RFC 4648 §7), lowercase.
3
+ const BASE32HEX = '0123456789abcdefghijklmnopqrstuv';
4
+ function toBase32Hex(bytes) {
5
+ let out = '';
6
+ let buffer = 0;
7
+ let bits = 0;
8
+ for (const byte of bytes){
9
+ buffer = buffer << 8 | byte;
10
+ bits += 8;
11
+ while(bits >= 5){
12
+ bits -= 5;
13
+ out += BASE32HEX[buffer >> bits & 31];
14
+ }
15
+ buffer &= (1 << bits) - 1;
16
+ }
17
+ if (bits > 0) out += BASE32HEX[buffer << 5 - bits & 31];
18
+ return out;
19
+ }
20
+ /**
21
+ * The Google Calendar event id a create inserts under, derived from the
22
+ * operation's deterministic event id: the lowercase base32hex encoding (no
23
+ * padding) of a 32-byte digest of it, 52 characters — inside Google's allowed
24
+ * alphabet (`a-v`, `0-9`) and length (5 to 1024).
25
+ *
26
+ * Every attempt of the same operation derives the same id, so a create that is
27
+ * re-issued after its response was lost collides with its own earlier effect
28
+ * (a 409) instead of inserting a duplicate event. The mapping is one-way and
29
+ * versioned: changing it would re-key every create not yet confirmed.
30
+ */ export function deriveEventID(deterministicEventID) {
31
+ return toBase32Hex(digestJSON({
32
+ v: 'gcal-event-id',
33
+ deterministicEventID
34
+ }));
35
+ }