@kubun/connector-google-calendar 0.17.1 → 0.17.3
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/account-key.d.ts +10 -0
- package/lib/account-key.js +19 -0
- package/lib/actions.d.ts +1 -0
- package/lib/actions.js +76 -24
- package/lib/api.d.ts +31 -4
- package/lib/api.js +103 -69
- package/lib/connector.js +29 -4
- package/lib/errors.d.ts +22 -0
- package/lib/errors.js +31 -0
- package/lib/event-id.d.ts +12 -0
- package/lib/event-id.js +35 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.js +5 -0
- package/lib/intent.d.ts +23 -0
- package/lib/intent.js +86 -0
- package/lib/model.d.ts +24 -13
- package/lib/model.js +38 -24
- package/lib/predict.d.ts +8 -0
- package/lib/predict.js +12 -0
- package/lib/provider.d.ts +1 -0
- package/lib/provider.js +125 -53
- package/lib/target.d.ts +11 -0
- package/lib/target.js +68 -0
- package/package.json +12 -6
|
@@ -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
package/lib/actions.js
CHANGED
|
@@ -1,59 +1,111 @@
|
|
|
1
|
-
import { createEvent,
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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.
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
const
|
|
29
|
-
const
|
|
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 (
|
|
33
|
-
if (
|
|
78
|
+
if (knownEventID != null) {
|
|
79
|
+
if (knownCalendarID == null) {
|
|
34
80
|
throw new Error('calendarID is required for updating calendar events');
|
|
35
81
|
}
|
|
36
|
-
eventID =
|
|
37
|
-
calendarID =
|
|
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 (
|
|
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
|
-
|
|
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,5 +1,7 @@
|
|
|
1
|
-
import type
|
|
2
|
-
|
|
1
|
+
import { type EntityRecord } from '@kubun/connector';
|
|
2
|
+
import { type GoogleAPIClient } from '@kubun/provider-google';
|
|
3
|
+
export { type ConnectorErrorKind, classifyHTTPStatus } from '@kubun/connector';
|
|
4
|
+
export declare function calendarAPIClient(accessToken: string, fetch?: typeof globalThis.fetch): GoogleAPIClient;
|
|
3
5
|
export type GoogleCalendarResource = {
|
|
4
6
|
id: string;
|
|
5
7
|
summary: string;
|
|
@@ -23,6 +25,7 @@ export type GoogleEventDateTime = {
|
|
|
23
25
|
};
|
|
24
26
|
export type GoogleEventResource = {
|
|
25
27
|
id: string;
|
|
28
|
+
etag?: string;
|
|
26
29
|
summary?: string;
|
|
27
30
|
description?: string;
|
|
28
31
|
location?: string;
|
|
@@ -47,26 +50,42 @@ export declare function buildEventsListURL(params: {
|
|
|
47
50
|
pageToken?: string;
|
|
48
51
|
timeMin?: string;
|
|
49
52
|
}): string;
|
|
50
|
-
export declare function mapCalendarToEntity(calendar: GoogleCalendarResource): EntityRecord;
|
|
53
|
+
export declare function mapCalendarToEntity(calendar: GoogleCalendarResource, accountKey?: string): EntityRecord;
|
|
51
54
|
export declare function mapEventToEntity(event: GoogleEventResource, calendarID: string): EntityRecord;
|
|
52
55
|
export declare function buildEventFromParams(params: Record<string, unknown>, options?: {
|
|
53
56
|
create?: boolean;
|
|
57
|
+
eventID?: string;
|
|
54
58
|
}): Record<string, unknown>;
|
|
55
|
-
export declare function
|
|
59
|
+
export declare function getCalendarListEntry(config: {
|
|
56
60
|
accessToken: string;
|
|
57
61
|
calendarID: string;
|
|
58
62
|
fetch?: typeof globalThis.fetch;
|
|
59
63
|
}): Promise<GoogleCalendarResource>;
|
|
64
|
+
export declare function splitEventSourceID(sourceID: string): {
|
|
65
|
+
calendarID: string;
|
|
66
|
+
eventID: string;
|
|
67
|
+
};
|
|
60
68
|
export declare function getEvent(config: {
|
|
61
69
|
accessToken: string;
|
|
62
70
|
calendarID: string;
|
|
63
71
|
eventID: string;
|
|
64
72
|
fetch?: typeof globalThis.fetch;
|
|
65
73
|
}): Promise<EntityRecord>;
|
|
74
|
+
export declare function getEventWithETag(config: {
|
|
75
|
+
accessToken: string;
|
|
76
|
+
calendarID: string;
|
|
77
|
+
eventID: string;
|
|
78
|
+
fetch?: typeof globalThis.fetch;
|
|
79
|
+
}): Promise<{
|
|
80
|
+
record: EntityRecord;
|
|
81
|
+
etag?: string;
|
|
82
|
+
}>;
|
|
66
83
|
export declare function createEvent(config: {
|
|
67
84
|
accessToken: string;
|
|
68
85
|
calendarID: string;
|
|
69
86
|
params: Record<string, unknown>;
|
|
87
|
+
/** The provider event id to insert under (see `deriveEventID`); takes precedence over `params.id`. */
|
|
88
|
+
eventID?: string;
|
|
70
89
|
fetch?: typeof globalThis.fetch;
|
|
71
90
|
}): Promise<EntityRecord>;
|
|
72
91
|
export declare function updateEvent(config: {
|
|
@@ -74,5 +93,13 @@ export declare function updateEvent(config: {
|
|
|
74
93
|
calendarID: string;
|
|
75
94
|
eventID: string;
|
|
76
95
|
params: Record<string, unknown>;
|
|
96
|
+
/**
|
|
97
|
+
* The event's etag as last fetched, sent as If-Match: a change at Google
|
|
98
|
+
* since then fails the PATCH with a 412 (outcome `unknown`, resolved by
|
|
99
|
+
* recovery's reconcile read) instead of being overwritten. It is never
|
|
100
|
+
* re-read right before the write — that would guard nothing. Absent, the
|
|
101
|
+
* PATCH is unconditional.
|
|
102
|
+
*/
|
|
103
|
+
etag?: string | null;
|
|
77
104
|
fetch?: typeof globalThis.fetch;
|
|
78
105
|
}): Promise<EntityRecord>;
|
package/lib/api.js
CHANGED
|
@@ -1,11 +1,26 @@
|
|
|
1
|
+
import { connectorHTTPError } from '@kubun/connector';
|
|
2
|
+
import { createGoogleAPIClient } from '@kubun/provider-google';
|
|
3
|
+
import { GoogleCalendarEventConflict } from './errors.js';
|
|
4
|
+
import { eventHoldsIntent } from './intent.js';
|
|
5
|
+
export { classifyHTTPStatus } from '@kubun/connector';
|
|
1
6
|
const CALENDAR_BASE_URL = 'https://www.googleapis.com/calendar/v3';
|
|
2
|
-
export
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
7
|
+
export function calendarAPIClient(accessToken, fetch) {
|
|
8
|
+
return createGoogleAPIClient({
|
|
9
|
+
api: 'Google Calendar API',
|
|
10
|
+
accessToken,
|
|
11
|
+
fetch,
|
|
12
|
+
toError: ({ httpStatus, message })=>connectorHTTPError(message, httpStatus)
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A create that collided with an existing event it could not confirm as its
|
|
17
|
+
* own. Outcome-unknown, not a rejection: the event may be this create's own
|
|
18
|
+
* earlier effect, edited since. `code: 'CONFLICT'` and `sourceID` let
|
|
19
|
+
* write-back recovery reconcile against the event.
|
|
20
|
+
*/ function eventConflict(sourceID, cause) {
|
|
21
|
+
return new GoogleCalendarEventConflict(sourceID, cause != null ? {
|
|
22
|
+
cause
|
|
23
|
+
} : undefined);
|
|
9
24
|
}
|
|
10
25
|
export function buildCalendarListURL(params) {
|
|
11
26
|
const url = new URL(`${CALENDAR_BASE_URL}/users/me/calendarList`);
|
|
@@ -37,7 +52,7 @@ export function buildEventsListURL(params) {
|
|
|
37
52
|
}
|
|
38
53
|
return url.toString();
|
|
39
54
|
}
|
|
40
|
-
export function mapCalendarToEntity(calendar) {
|
|
55
|
+
export function mapCalendarToEntity(calendar, accountKey) {
|
|
41
56
|
return {
|
|
42
57
|
sourceService: 'google-calendar',
|
|
43
58
|
sourceID: calendar.id,
|
|
@@ -46,7 +61,10 @@ export function mapCalendarToEntity(calendar) {
|
|
|
46
61
|
description: calendar.description,
|
|
47
62
|
timeZone: calendar.timeZone,
|
|
48
63
|
primary: calendar.primary ?? false,
|
|
49
|
-
backgroundColor: calendar.backgroundColor
|
|
64
|
+
backgroundColor: calendar.backgroundColor,
|
|
65
|
+
...accountKey != null ? {
|
|
66
|
+
accountKey
|
|
67
|
+
} : {}
|
|
50
68
|
};
|
|
51
69
|
}
|
|
52
70
|
// Google gives all-day events a bare calendar date (`YYYY-MM-DD`); the model's
|
|
@@ -81,17 +99,19 @@ export function mapEventToEntity(event, calendarID) {
|
|
|
81
99
|
calendar: {
|
|
82
100
|
sourceService: 'google-calendar',
|
|
83
101
|
sourceID: calendarID
|
|
84
|
-
}
|
|
102
|
+
},
|
|
103
|
+
etag: event.etag
|
|
85
104
|
};
|
|
86
105
|
}
|
|
87
106
|
export function buildEventFromParams(params, options) {
|
|
88
107
|
const event = {};
|
|
89
108
|
const allDay = params.allDay === true;
|
|
90
|
-
// A caller-
|
|
109
|
+
// A caller-chosen id makes a create idempotent (Google rejects a duplicate
|
|
91
110
|
// with 409). Only insert accepts an id; PATCH must never carry one, so gate it
|
|
92
111
|
// to the create path — the builder is shared with updateEvent.
|
|
93
|
-
|
|
94
|
-
|
|
112
|
+
const eventID = options?.eventID ?? params.id;
|
|
113
|
+
if (options?.create === true && eventID != null) {
|
|
114
|
+
event.id = eventID;
|
|
95
115
|
}
|
|
96
116
|
if (params.title != null) {
|
|
97
117
|
event.summary = params.title;
|
|
@@ -127,83 +147,97 @@ export function buildEventFromParams(params, options) {
|
|
|
127
147
|
}
|
|
128
148
|
return event;
|
|
129
149
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
150
|
+
// One calendar as the account's calendar list holds it (calendarList.get) —
|
|
151
|
+
// the same resource sync maps, with the per-account `primary` flag and
|
|
152
|
+
// colour. calendars.get carries neither, so a re-pull from it would clear
|
|
153
|
+
// `primary` and break resolving the 'primary' alias.
|
|
154
|
+
export async function getCalendarListEntry(config) {
|
|
155
|
+
const url = `${CALENDAR_BASE_URL}/users/me/calendarList/${encodeURIComponent(config.calendarID)}`;
|
|
156
|
+
return calendarAPIClient(config.accessToken, config.fetch).get(url);
|
|
157
|
+
}
|
|
158
|
+
// A composite calendarID/eventID sourceID splits on the LAST '/': an eventID
|
|
159
|
+
// never contains one, but a calendarID (email-like) may.
|
|
160
|
+
export function splitEventSourceID(sourceID) {
|
|
161
|
+
const slash = sourceID.lastIndexOf('/');
|
|
162
|
+
if (slash <= 0 || slash === sourceID.length - 1) {
|
|
163
|
+
throw new Error(`Cannot recover eventID from sourceID "${sourceID}"`);
|
|
141
164
|
}
|
|
142
|
-
return
|
|
165
|
+
return {
|
|
166
|
+
calendarID: sourceID.slice(0, slash),
|
|
167
|
+
eventID: sourceID.slice(slash + 1)
|
|
168
|
+
};
|
|
143
169
|
}
|
|
144
|
-
|
|
145
|
-
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
170
|
+
async function fetchEventResource(config) {
|
|
146
171
|
const url = `${CALENDAR_BASE_URL}/calendars/${encodeURIComponent(config.calendarID)}/events/${encodeURIComponent(config.eventID)}`;
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
});
|
|
152
|
-
if (!response.ok) {
|
|
153
|
-
const errorMessage = await parseGoogleError(response);
|
|
154
|
-
throw new Error(`Google Calendar API error: ${errorMessage}`);
|
|
155
|
-
}
|
|
156
|
-
const data = await response.json();
|
|
172
|
+
return calendarAPIClient(config.accessToken, config.fetch).get(url);
|
|
173
|
+
}
|
|
174
|
+
export async function getEvent(config) {
|
|
175
|
+
const data = await fetchEventResource(config);
|
|
157
176
|
return mapEventToEntity(data, config.calendarID);
|
|
158
177
|
}
|
|
178
|
+
// Same as getEvent, but also surfaces the provider's etag/revision — used by
|
|
179
|
+
// write-back recovery's reconcile read, which reports a providerETag
|
|
180
|
+
// alongside the record.
|
|
181
|
+
export async function getEventWithETag(config) {
|
|
182
|
+
const data = await fetchEventResource(config);
|
|
183
|
+
return {
|
|
184
|
+
record: mapEventToEntity(data, config.calendarID),
|
|
185
|
+
etag: data.etag
|
|
186
|
+
};
|
|
187
|
+
}
|
|
159
188
|
export async function createEvent(config) {
|
|
160
|
-
const
|
|
189
|
+
const client = calendarAPIClient(config.accessToken, config.fetch);
|
|
161
190
|
const event = buildEventFromParams(config.params, {
|
|
162
|
-
create: true
|
|
191
|
+
create: true,
|
|
192
|
+
eventID: config.eventID
|
|
163
193
|
});
|
|
194
|
+
const eventID = event.id;
|
|
164
195
|
const url = `${CALENDAR_BASE_URL}/calendars/${encodeURIComponent(config.calendarID)}/events`;
|
|
165
|
-
const response = await
|
|
196
|
+
const response = await client.request(url, {
|
|
166
197
|
method: 'POST',
|
|
167
|
-
|
|
168
|
-
Authorization: `Bearer ${config.accessToken}`,
|
|
169
|
-
'Content-Type': 'application/json'
|
|
170
|
-
},
|
|
171
|
-
body: JSON.stringify(event)
|
|
198
|
+
body: event
|
|
172
199
|
});
|
|
173
200
|
if (!response.ok) {
|
|
174
|
-
// A 409 on a caller-
|
|
175
|
-
// is
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
201
|
+
// A 409 on a caller-chosen id means an event already exists under it. It
|
|
202
|
+
// is this create's own earlier effect (a retry after a lost response) only
|
|
203
|
+
// if it holds what this create writes; anything else is a conflict naming
|
|
204
|
+
// the existing event, for recovery to reconcile — never a success.
|
|
205
|
+
if (response.status === 409 && eventID != null) {
|
|
206
|
+
const sourceID = `${config.calendarID}/${eventID}`;
|
|
207
|
+
let existing;
|
|
208
|
+
try {
|
|
209
|
+
existing = await getEvent({
|
|
210
|
+
accessToken: config.accessToken,
|
|
211
|
+
calendarID: config.calendarID,
|
|
212
|
+
eventID,
|
|
213
|
+
fetch: config.fetch
|
|
214
|
+
});
|
|
215
|
+
} catch (err) {
|
|
216
|
+
throw eventConflict(sourceID, err);
|
|
217
|
+
}
|
|
218
|
+
if (!eventHoldsIntent({
|
|
219
|
+
op: 'create',
|
|
220
|
+
intent: config.params,
|
|
221
|
+
record: existing
|
|
222
|
+
})) {
|
|
223
|
+
throw eventConflict(sourceID);
|
|
224
|
+
}
|
|
225
|
+
return existing;
|
|
184
226
|
}
|
|
185
|
-
|
|
186
|
-
throw new Error(`Google Calendar API error: ${errorMessage}`);
|
|
227
|
+
return client.fail(response);
|
|
187
228
|
}
|
|
188
229
|
const data = await response.json();
|
|
189
230
|
return mapEventToEntity(data, config.calendarID);
|
|
190
231
|
}
|
|
191
232
|
export async function updateEvent(config) {
|
|
192
|
-
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
193
233
|
const event = buildEventFromParams(config.params);
|
|
194
234
|
const url = `${CALENDAR_BASE_URL}/calendars/${encodeURIComponent(config.calendarID)}/events/${encodeURIComponent(config.eventID)}`;
|
|
195
|
-
const
|
|
235
|
+
const data = await calendarAPIClient(config.accessToken, config.fetch).send(url, {
|
|
196
236
|
method: 'PATCH',
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
'
|
|
200
|
-
}
|
|
201
|
-
body: JSON.stringify(event)
|
|
237
|
+
body: event,
|
|
238
|
+
headers: config.etag != null ? {
|
|
239
|
+
'If-Match': config.etag
|
|
240
|
+
} : {}
|
|
202
241
|
});
|
|
203
|
-
if (!response.ok) {
|
|
204
|
-
const errorMessage = await parseGoogleError(response);
|
|
205
|
-
throw new Error(`Google Calendar API error: ${errorMessage}`);
|
|
206
|
-
}
|
|
207
|
-
const data = await response.json();
|
|
208
242
|
return mapEventToEntity(data, config.calendarID);
|
|
209
243
|
}
|
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
|
};
|
package/lib/errors.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
}
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
}
|
|
@@ -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;
|
package/lib/event-id.js
ADDED
|
@@ -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
|
+
}
|