@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/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 +29 -3
- package/lib/api.js +101 -26
- package/lib/connector.js +29 -4
- package/lib/errors.d.ts +32 -0
- package/lib/errors.js +46 -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 +11 -0
- package/lib/model.js +34 -0
- package/lib/predict.d.ts +8 -0
- package/lib/predict.js +12 -0
- package/lib/provider.d.ts +2 -0
- package/lib/provider.js +162 -28
- package/lib/target.d.ts +11 -0
- package/lib/target.js +68 -0
- package/package.json +11 -5
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';
|
package/lib/intent.d.ts
ADDED
|
@@ -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({
|
package/lib/predict.d.ts
ADDED
|
@@ -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 {
|
|
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
|
-
|
|
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(
|
|
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)=>
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
221
|
+
entities: activeCalendars.map((c)=>({
|
|
222
|
+
present: true,
|
|
223
|
+
record: mapCalendarToEntity(c, accountKey),
|
|
224
|
+
sourceFetchHLC: calendarListToken
|
|
225
|
+
})),
|
|
195
226
|
deleted: deletedCalendarIDs.map((id)=>({
|
|
196
|
-
|
|
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)=>
|
|
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
|
-
|
|
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) {
|
package/lib/target.d.ts
ADDED
|
@@ -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.
|
|
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.
|
|
19
|
-
"@kubun/credential-types": "^0.17.
|
|
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.
|
|
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"
|