@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/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 { 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 { 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
  };
@@ -36,6 +41,7 @@ export declare const googleCalendarDefinition: {
36
41
  readonly required: ["summary", "sourceService", "sourceID", "lastSyncedAt"];
37
42
  readonly additionalProperties: false;
38
43
  readonly definitions: {
44
+ readonly TimeZone: import("json-schema-typed").JSONSchema.String;
39
45
  readonly SourceService: {
40
46
  readonly type: "string";
41
47
  readonly title: "SourceService";
@@ -54,7 +60,6 @@ export declare const googleCalendarDefinition: {
54
60
  readonly title: "URL";
55
61
  };
56
62
  readonly DateTime: import("json-schema-typed").JSONSchema.String;
57
- readonly TimeZone: import("json-schema-typed").JSONSchema.String;
58
63
  };
59
64
  };
60
65
  readonly fieldsMeta: {};
@@ -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
  };
@@ -123,24 +131,12 @@ export declare const googleCalendarEventDefinition: {
123
131
  readonly required: ["title", "startAt", "allDay", "calendarID", "calendar", "sourceService", "sourceID", "lastSyncedAt"];
124
132
  readonly additionalProperties: false;
125
133
  readonly definitions: {
126
- readonly SourceService: {
127
- readonly type: "string";
128
- readonly title: "SourceService";
129
- readonly minLength: 1;
130
- readonly maxLength: 50;
131
- };
132
134
  readonly SourceID: {
133
135
  readonly type: "string";
134
136
  readonly title: "SourceID";
135
137
  readonly minLength: 1;
136
138
  readonly maxLength: 1400;
137
139
  };
138
- readonly SourceURL: {
139
- readonly type: "string";
140
- readonly format: "uri";
141
- readonly title: "URL";
142
- };
143
- readonly DateTime: import("json-schema-typed").JSONSchema.String;
144
140
  readonly TimeZone: import("json-schema-typed").JSONSchema.String;
145
141
  readonly HtmlLink: {
146
142
  readonly type: "string";
@@ -157,6 +153,18 @@ export declare const googleCalendarEventDefinition: {
157
153
  readonly title: "EventStatus";
158
154
  readonly enum: ["CONFIRMED", "TENTATIVE", "CANCELLED"];
159
155
  };
156
+ readonly SourceService: {
157
+ readonly type: "string";
158
+ readonly title: "SourceService";
159
+ readonly minLength: 1;
160
+ readonly maxLength: 50;
161
+ };
162
+ readonly SourceURL: {
163
+ readonly type: "string";
164
+ readonly format: "uri";
165
+ readonly title: "URL";
166
+ };
167
+ readonly DateTime: import("json-schema-typed").JSONSchema.String;
160
168
  };
161
169
  };
162
170
  readonly fieldsMeta: {
@@ -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
@@ -1,7 +1,7 @@
1
- import { connectorClusters, connectorModelIDs } from '@kubun/connector';
1
+ import { connectorClusters, connectorModelIDs, externalEntityDefinition } from '@kubun/connector';
2
2
  import { modelIDs, modelsClusters } from '@kubun/models';
3
3
  import { GraphBuilder } from '@kubun/protocol';
4
- import { dateTimeSchema, docIDSchema, timeZoneSchema, urlSchema } from '@kubun/scalars';
4
+ import { docIDSchema, timeZoneSchema, urlSchema } from '@kubun/scalars';
5
5
  export const googleCalendarDefinition = {
6
6
  name: 'GoogleCalendar',
7
7
  behavior: 'default',
@@ -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
  },
@@ -47,20 +52,7 @@ export const googleCalendarDefinition = {
47
52
  ],
48
53
  additionalProperties: false,
49
54
  definitions: {
50
- SourceService: {
51
- type: 'string',
52
- title: 'SourceService',
53
- minLength: 1,
54
- maxLength: 50
55
- },
56
- SourceID: {
57
- type: 'string',
58
- title: 'SourceID',
59
- minLength: 1,
60
- maxLength: 500
61
- },
62
- SourceURL: urlSchema,
63
- DateTime: dateTimeSchema,
55
+ ...externalEntityDefinition.schema.definitions,
64
56
  TimeZone: timeZoneSchema
65
57
  }
66
58
  },
@@ -114,6 +106,9 @@ export const googleCalendarEventDefinition = {
114
106
  calendarID: {
115
107
  type: 'string'
116
108
  },
109
+ etag: {
110
+ type: 'string'
111
+ },
117
112
  calendar: {
118
113
  $ref: '#/definitions/CalendarDocID'
119
114
  },
@@ -142,12 +137,7 @@ export const googleCalendarEventDefinition = {
142
137
  ],
143
138
  additionalProperties: false,
144
139
  definitions: {
145
- SourceService: {
146
- type: 'string',
147
- title: 'SourceService',
148
- minLength: 1,
149
- maxLength: 50
150
- },
140
+ ...externalEntityDefinition.schema.definitions,
151
141
  // A composite calendarID/eventID: calendar id (email-like, up to ~254) +
152
142
  // '/' + a Google event id (up to 1024). 500 is too small.
153
143
  SourceID: {
@@ -156,8 +146,6 @@ export const googleCalendarEventDefinition = {
156
146
  minLength: 1,
157
147
  maxLength: 1400
158
148
  },
159
- SourceURL: urlSchema,
160
- DateTime: dateTimeSchema,
161
149
  TimeZone: timeZoneSchema,
162
150
  HtmlLink: urlSchema,
163
151
  CalendarDocID: docIDSchema,
@@ -177,6 +165,32 @@ export const googleCalendarEventDefinition = {
177
165
  type: 'document',
178
166
  model: '#0'
179
167
  }
168
+ },
169
+ // lastSyncedAt is a device-local sync-bookkeeping annotation, not fetched
170
+ // provider content, so it stays outside the group.
171
+ providerMergePolicy: {
172
+ group: [
173
+ 'title',
174
+ 'startAt',
175
+ 'endAt',
176
+ 'allDay',
177
+ 'timeZone',
178
+ 'description',
179
+ 'location',
180
+ 'status',
181
+ 'htmlLink',
182
+ 'recurringEventID',
183
+ 'eventID',
184
+ 'calendarID',
185
+ 'calendar',
186
+ 'sourceService',
187
+ 'sourceID',
188
+ 'sourceURL',
189
+ // The provider revision of this state: an update is written
190
+ // conditionally on it (If-Match), so it must travel with the fields it
191
+ // versions.
192
+ 'etag'
193
+ ]
180
194
  }
181
195
  };
182
196
  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,6 +5,7 @@ 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;
package/lib/provider.js CHANGED
@@ -1,39 +1,12 @@
1
- import { resolveModelID } from '@kubun/connector';
2
- import { buildCalendarListURL, buildEventsListURL, mapCalendarToEntity, mapEventToEntity, parseGoogleError } from './api.js';
1
+ import { ConnectorError, fetchOrAbsent, parseMaxAgeDays, resolveModelID } from '@kubun/connector';
2
+ import { deriveAccountKey } from './account-key.js';
3
+ import { buildCalendarListURL, buildEventsListURL, calendarAPIClient, getCalendarListEntry, getEvent, getEventWithETag, mapCalendarToEntity, mapEventToEntity, splitEventSourceID } from './api.js';
3
4
  import { googleCalendarClusters } from './model.js';
4
5
  const CALENDAR_MODEL_ID = resolveModelID(googleCalendarClusters, 'GoogleCalendar');
5
6
  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
- async function fetchJSON(url, credential, fetchFn, signal) {
18
- const response = await fetchFn(url, {
19
- headers: {
20
- Authorization: `Bearer ${credential.accessToken}`
21
- },
22
- signal
23
- });
24
- if (!response.ok) {
25
- throw new GoogleCalendarAPIError(response.status, await parseGoogleError(response));
26
- }
27
- return await response.json();
28
- }
29
7
  function computeTimeMin(boundary) {
30
- if (boundary.maxAge == null) return undefined;
31
- // Parse PnD format (e.g. "P30D" = 30 days)
32
- const match = /^P(\d+)D$/i.exec(boundary.maxAge);
33
- if (match == null) return undefined;
34
- const rawDays = match[1];
35
- if (rawDays == null) return undefined;
36
- const days = Number.parseInt(rawDays, 10);
8
+ const days = parseMaxAgeDays(boundary);
9
+ if (days == null) return undefined;
37
10
  const date = new Date();
38
11
  date.setDate(date.getDate() - days);
39
12
  return date.toISOString();
@@ -46,7 +19,9 @@ async function* fetchAllCalendars(params) {
46
19
  syncToken: isFirstPage ? params.syncToken : undefined,
47
20
  pageToken
48
21
  });
49
- const data = await fetchJSON(url, params.credential, params.fetchFn, params.signal);
22
+ const data = await params.client.get(url, {
23
+ signal: params.signal
24
+ });
50
25
  yield {
51
26
  items: data.items ?? [],
52
27
  nextSyncToken: data.nextSyncToken,
@@ -66,7 +41,9 @@ async function* fetchEventsForCalendar(params) {
66
41
  pageToken,
67
42
  timeMin: params.timeMin
68
43
  });
69
- const data = await fetchJSON(url, params.credential, params.fetchFn, params.signal);
44
+ const data = await params.client.get(url, {
45
+ signal: params.signal
46
+ });
70
47
  yield {
71
48
  items: data.items ?? [],
72
49
  nextSyncToken: data.nextSyncToken
@@ -77,7 +54,13 @@ async function* fetchEventsForCalendar(params) {
77
54
  }
78
55
  export function createGoogleCalendarProvider(config) {
79
56
  const fetchFn = config.fetch ?? globalThis.fetch;
57
+ const client = calendarAPIClient(config.credential.accessToken, fetchFn);
80
58
  const excludeSet = new Set(config.excludeCalendars ?? []);
59
+ const mintFetchHLC = config.mintFetchHLC;
60
+ // Derived once per provider instance — a pure function of (iss,sub), so it is
61
+ // stable across refresh and re-auth. Absent when the credential predates
62
+ // providerAccount capture.
63
+ const accountKey = config.credential.providerAccount != null ? deriveAccountKey(config.credential.providerAccount) : undefined;
81
64
  async function* fetchAllEntities(params) {
82
65
  // Reuse a window frozen by a prior sync (carried in an expired checkpoint on
83
66
  // the 410 resync path, or a re-driven initial) instead of recomputing from
@@ -89,8 +72,7 @@ export function createGoogleCalendarProvider(config) {
89
72
  const allCalendars = [];
90
73
  let calendarListSyncToken;
91
74
  for await (const page of fetchAllCalendars({
92
- credential: config.credential,
93
- fetchFn,
75
+ client,
94
76
  signal: params.signal
95
77
  })){
96
78
  for (const cal of page.items){
@@ -103,9 +85,14 @@ export function createGoogleCalendarProvider(config) {
103
85
  }
104
86
  }
105
87
  // Yield calendar batch (no checkpoint yet — will be on the last event batch)
88
+ const calendarListToken = mintFetchHLC();
106
89
  yield {
107
90
  modelID: CALENDAR_MODEL_ID,
108
- entities: allCalendars.map(mapCalendarToEntity),
91
+ entities: allCalendars.map((c)=>({
92
+ present: true,
93
+ record: mapCalendarToEntity(c, accountKey),
94
+ sourceFetchHLC: calendarListToken
95
+ })),
109
96
  hasMore: true
110
97
  };
111
98
  // Phase 2: fetch events per calendar
@@ -116,8 +103,7 @@ export function createGoogleCalendarProvider(config) {
116
103
  let eventsSyncToken;
117
104
  for await (const page of fetchEventsForCalendar({
118
105
  calendarID: calendar.id,
119
- credential: config.credential,
120
- fetchFn,
106
+ client,
121
107
  timeMin,
122
108
  signal: params.signal
123
109
  })){
@@ -141,14 +127,20 @@ export function createGoogleCalendarProvider(config) {
141
127
  // Split items: cancelled → deleted, others → entities
142
128
  const activeItems = page.items.filter((e)=>e.status !== 'cancelled');
143
129
  const cancelledItems = page.items.filter((e)=>e.status === 'cancelled');
130
+ const pageToken = mintFetchHLC();
144
131
  yield {
145
132
  modelID: CALENDAR_EVENT_MODEL_ID,
146
- entities: activeItems.map((event)=>mapEventToEntity(event, calendar.id)),
133
+ entities: activeItems.map((event)=>({
134
+ present: true,
135
+ record: mapEventToEntity(event, calendar.id),
136
+ sourceFetchHLC: pageToken
137
+ })),
147
138
  // Match the composite identity mapEventToEntity assigns, so the
148
139
  // tombstone lookup hits the calendar-namespaced document.
149
140
  deleted: cancelledItems.map((e)=>({
150
- sourceService: 'google-calendar',
151
- sourceID: `${calendar.id}/${e.id}`
141
+ present: false,
142
+ sourceID: `${calendar.id}/${e.id}`,
143
+ sourceFetchHLC: pageToken
152
144
  })),
153
145
  hasMore: !(isLastCalendar && isLastPage),
154
146
  checkpoint
@@ -185,8 +177,7 @@ export function createGoogleCalendarProvider(config) {
185
177
  const deletedCalendarIDs = [];
186
178
  let newCalendarListSyncToken;
187
179
  for await (const page of fetchAllCalendars({
188
- credential: config.credential,
189
- fetchFn,
180
+ client,
190
181
  syncToken: checkpoint.calendarListSyncToken,
191
182
  signal: params.signal
192
183
  })){
@@ -202,12 +193,18 @@ export function createGoogleCalendarProvider(config) {
202
193
  }
203
194
  }
204
195
  // Yield calendar batch (active + deletions)
196
+ const calendarListToken = mintFetchHLC();
205
197
  yield {
206
198
  modelID: CALENDAR_MODEL_ID,
207
- entities: activeCalendars.map(mapCalendarToEntity),
199
+ entities: activeCalendars.map((c)=>({
200
+ present: true,
201
+ record: mapCalendarToEntity(c, accountKey),
202
+ sourceFetchHLC: calendarListToken
203
+ })),
208
204
  deleted: deletedCalendarIDs.map((id)=>({
209
- sourceService: 'google-calendar',
210
- sourceID: id
205
+ present: false,
206
+ sourceID: id,
207
+ sourceFetchHLC: calendarListToken
211
208
  })),
212
209
  hasMore: true
213
210
  };
@@ -244,8 +241,7 @@ export function createGoogleCalendarProvider(config) {
244
241
  let eventsSyncToken;
245
242
  for await (const page of fetchEventsForCalendar({
246
243
  calendarID,
247
- credential: config.credential,
248
- fetchFn,
244
+ client,
249
245
  syncToken: storedToken,
250
246
  timeMin: isNew ? timeMin : undefined,
251
247
  signal: params.signal
@@ -269,14 +265,20 @@ export function createGoogleCalendarProvider(config) {
269
265
  // Split items: cancelled → deleted, others → entities
270
266
  const activeItems = page.items.filter((e)=>e.status !== 'cancelled');
271
267
  const cancelledItems = page.items.filter((e)=>e.status === 'cancelled');
268
+ const pageToken = mintFetchHLC();
272
269
  yield {
273
270
  modelID: CALENDAR_EVENT_MODEL_ID,
274
- entities: activeItems.map((event)=>mapEventToEntity(event, calendarID)),
271
+ entities: activeItems.map((event)=>({
272
+ present: true,
273
+ record: mapEventToEntity(event, calendarID),
274
+ sourceFetchHLC: pageToken
275
+ })),
275
276
  // Match the composite identity mapEventToEntity assigns, so the
276
277
  // tombstone lookup hits the calendar-namespaced document.
277
278
  deleted: cancelledItems.map((e)=>({
278
- sourceService: 'google-calendar',
279
- sourceID: `${calendarID}/${e.id}`
279
+ present: false,
280
+ sourceID: `${calendarID}/${e.id}`,
281
+ sourceFetchHLC: pageToken
280
282
  })),
281
283
  hasMore: !(isLastCalendar && isLastPage),
282
284
  checkpoint: newCheckpoint
@@ -303,11 +305,81 @@ export function createGoogleCalendarProvider(config) {
303
305
  };
304
306
  }
305
307
  }
308
+ // Targeted single-entity fetch (anti-entropy repair verification). Mints its
309
+ // own fetch-order token at request start, independent of any batch token
310
+ // (see ServerProviderFactory.mintFetchHLC's doc). `model` distinguishes the
311
+ // account-level GoogleCalendar resource from a GoogleCalendarEvent — only
312
+ // the latter's sourceID is the composite calendarID/eventID.
313
+ async function fetchEntity(model, sourceID) {
314
+ const sourceFetchHLC = mintFetchHLC();
315
+ return fetchOrAbsent({
316
+ sourceID,
317
+ sourceFetchHLC,
318
+ read: async ()=>{
319
+ if (model === CALENDAR_MODEL_ID) {
320
+ const calendar = await getCalendarListEntry({
321
+ accessToken: config.credential.accessToken,
322
+ calendarID: sourceID,
323
+ fetch: fetchFn
324
+ });
325
+ return {
326
+ record: mapCalendarToEntity(calendar, accountKey)
327
+ };
328
+ }
329
+ const { calendarID, eventID } = splitEventSourceID(sourceID);
330
+ const record = await getEvent({
331
+ accessToken: config.credential.accessToken,
332
+ calendarID,
333
+ eventID,
334
+ fetch: fetchFn
335
+ });
336
+ return {
337
+ record
338
+ };
339
+ }
340
+ });
341
+ }
342
+ // Provider-authoritative re-read for write-back recovery. Unlike
343
+ // fetchEntity, the present:true branch also surfaces a providerETag when the
344
+ // provider reports one — the current revision an update's If-Match can
345
+ // condition on.
346
+ async function reconcile(model, sourceID) {
347
+ const sourceFetchHLC = mintFetchHLC();
348
+ return fetchOrAbsent({
349
+ sourceID,
350
+ sourceFetchHLC,
351
+ read: async ()=>{
352
+ if (model === CALENDAR_MODEL_ID) {
353
+ const calendar = await getCalendarListEntry({
354
+ accessToken: config.credential.accessToken,
355
+ calendarID: sourceID,
356
+ fetch: fetchFn
357
+ });
358
+ return {
359
+ record: mapCalendarToEntity(calendar, accountKey)
360
+ };
361
+ }
362
+ const { calendarID, eventID } = splitEventSourceID(sourceID);
363
+ const { record, etag } = await getEventWithETag({
364
+ accessToken: config.credential.accessToken,
365
+ calendarID,
366
+ eventID,
367
+ fetch: fetchFn
368
+ });
369
+ return {
370
+ record,
371
+ providerETag: etag
372
+ };
373
+ }
374
+ });
375
+ }
306
376
  return {
307
377
  supportedStrategies: [
308
378
  'on-demand'
309
379
  ],
310
380
  fetchAll: fetchAllEntities,
381
+ fetchEntity,
382
+ reconcile,
311
383
  async *fetchChanges (params) {
312
384
  const checkpoint = params.checkpoint;
313
385
  if (checkpoint == null) {
@@ -319,7 +391,7 @@ export function createGoogleCalendarProvider(config) {
319
391
  } catch (err) {
320
392
  // 410 Gone = the stored syncToken has expired; Google requires a full
321
393
  // resync. Discard the checkpoint by delegating to fetchAll.
322
- if (err instanceof GoogleCalendarAPIError && err.status === 410) {
394
+ if (err instanceof ConnectorError && err.httpStatus === 410) {
323
395
  yield* fetchAllEntities(params);
324
396
  return;
325
397
  }
@@ -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;