@kubun/connector-google-calendar 0.15.0 → 0.16.1

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/actions.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createEvent, updateEvent } from './api.js';
1
+ import { createEvent, getCalendar, updateEvent } from './api.js';
2
2
  export function createGoogleCalendarActionHandler(config) {
3
3
  return {
4
4
  async create (_model, params) {
@@ -6,22 +6,51 @@ export function createGoogleCalendarActionHandler(config) {
6
6
  if (calendarID == null) {
7
7
  throw new Error('calendarID is required for creating calendar events');
8
8
  }
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;
9
16
  return createEvent({
10
17
  accessToken: config.credential.accessToken,
11
- calendarID,
18
+ calendarID: resolvedCalendarID,
12
19
  params,
13
20
  fetch: config.fetch
14
21
  });
15
22
  },
16
23
  async update (_model, sourceID, params) {
17
- const calendarID = params.calendarID;
18
- if (calendarID == null) {
19
- throw new Error('calendarID is required for updating calendar events');
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;
30
+ let eventID;
31
+ let calendarID;
32
+ if (mergedEventID != null) {
33
+ if (mergedCalendarID == null) {
34
+ throw new Error('calendarID is required for updating calendar events');
35
+ }
36
+ eventID = mergedEventID;
37
+ calendarID = mergedCalendarID;
38
+ } else {
39
+ const slash = sourceID.lastIndexOf('/');
40
+ if (slash <= 0 || slash === sourceID.length - 1) {
41
+ throw new Error(`Cannot recover eventID from sourceID "${sourceID}"`);
42
+ }
43
+ const recoveredCalendarID = sourceID.slice(0, slash);
44
+ if (mergedCalendarID != null && mergedCalendarID !== recoveredCalendarID) {
45
+ throw new Error('calendarID does not match the event sourceID');
46
+ }
47
+ eventID = sourceID.slice(slash + 1);
48
+ calendarID = recoveredCalendarID;
20
49
  }
21
50
  return updateEvent({
22
51
  accessToken: config.credential.accessToken,
23
52
  calendarID,
24
- eventID: sourceID,
53
+ eventID,
25
54
  params,
26
55
  fetch: config.fetch
27
56
  });
package/lib/api.d.ts CHANGED
@@ -49,7 +49,20 @@ export declare function buildEventsListURL(params: {
49
49
  }): string;
50
50
  export declare function mapCalendarToEntity(calendar: GoogleCalendarResource): EntityRecord;
51
51
  export declare function mapEventToEntity(event: GoogleEventResource, calendarID: string): EntityRecord;
52
- export declare function buildEventFromParams(params: Record<string, unknown>): Record<string, unknown>;
52
+ export declare function buildEventFromParams(params: Record<string, unknown>, options?: {
53
+ create?: boolean;
54
+ }): Record<string, unknown>;
55
+ export declare function getCalendar(config: {
56
+ accessToken: string;
57
+ calendarID: string;
58
+ fetch?: typeof globalThis.fetch;
59
+ }): Promise<GoogleCalendarResource>;
60
+ export declare function getEvent(config: {
61
+ accessToken: string;
62
+ calendarID: string;
63
+ eventID: string;
64
+ fetch?: typeof globalThis.fetch;
65
+ }): Promise<EntityRecord>;
53
66
  export declare function createEvent(config: {
54
67
  accessToken: string;
55
68
  calendarID: string;
package/lib/api.js CHANGED
@@ -61,7 +61,11 @@ export function mapEventToEntity(event, calendarID) {
61
61
  const endAt = allDay ? allDayDateToDateTime(event.end.date) : event.end.dateTime;
62
62
  return {
63
63
  sourceService: 'google-calendar',
64
- sourceID: event.id,
64
+ // Google event ids are unique per calendar, not per account; namespace the
65
+ // identity by calendar so the same id in two calendars stays two documents.
66
+ // The bare id lives on in eventID for PATCH targeting.
67
+ sourceID: `${calendarID}/${event.id}`,
68
+ eventID: event.id,
65
69
  sourceURL: event.htmlLink,
66
70
  title: event.summary ?? '(No title)',
67
71
  description: event.description,
@@ -80,9 +84,15 @@ export function mapEventToEntity(event, calendarID) {
80
84
  }
81
85
  };
82
86
  }
83
- export function buildEventFromParams(params) {
87
+ export function buildEventFromParams(params, options) {
84
88
  const event = {};
85
89
  const allDay = params.allDay === true;
90
+ // A caller-supplied id makes a create idempotent (Google rejects a duplicate
91
+ // with 409). Only insert accepts an id; PATCH must never carry one, so gate it
92
+ // to the create path — the builder is shared with updateEvent.
93
+ if (options?.create === true && params.id != null) {
94
+ event.id = params.id;
95
+ }
86
96
  if (params.title != null) {
87
97
  event.summary = params.title;
88
98
  }
@@ -93,8 +103,11 @@ export function buildEventFromParams(params) {
93
103
  event.location = params.location;
94
104
  }
95
105
  if (params.startAt != null) {
106
+ // An imported all-day event carries startAt as a midnight-UTC timestamp
107
+ // (mapEventToEntity anchors bare dates for the date-time schema); slice back
108
+ // to YYYY-MM-DD, which Google requires for start.date/end.date.
96
109
  event.start = allDay ? {
97
- date: params.startAt
110
+ date: params.startAt.slice(0, 10)
98
111
  } : {
99
112
  dateTime: params.startAt,
100
113
  ...params.timeZone != null ? {
@@ -104,7 +117,7 @@ export function buildEventFromParams(params) {
104
117
  }
105
118
  if (params.endAt != null) {
106
119
  event.end = allDay ? {
107
- date: params.endAt
120
+ date: params.endAt.slice(0, 10)
108
121
  } : {
109
122
  dateTime: params.endAt,
110
123
  ...params.timeZone != null ? {
@@ -114,9 +127,40 @@ export function buildEventFromParams(params) {
114
127
  }
115
128
  return event;
116
129
  }
130
+ export async function getCalendar(config) {
131
+ const fetchFn = config.fetch ?? globalThis.fetch;
132
+ const url = `${CALENDAR_BASE_URL}/calendars/${encodeURIComponent(config.calendarID)}`;
133
+ const response = await fetchFn(url, {
134
+ headers: {
135
+ Authorization: `Bearer ${config.accessToken}`
136
+ }
137
+ });
138
+ if (!response.ok) {
139
+ const errorMessage = await parseGoogleError(response);
140
+ throw new Error(`Google Calendar API error: ${errorMessage}`);
141
+ }
142
+ return await response.json();
143
+ }
144
+ export async function getEvent(config) {
145
+ const fetchFn = config.fetch ?? globalThis.fetch;
146
+ const url = `${CALENDAR_BASE_URL}/calendars/${encodeURIComponent(config.calendarID)}/events/${encodeURIComponent(config.eventID)}`;
147
+ const response = await fetchFn(url, {
148
+ headers: {
149
+ Authorization: `Bearer ${config.accessToken}`
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();
157
+ return mapEventToEntity(data, config.calendarID);
158
+ }
117
159
  export async function createEvent(config) {
118
160
  const fetchFn = config.fetch ?? globalThis.fetch;
119
- const event = buildEventFromParams(config.params);
161
+ const event = buildEventFromParams(config.params, {
162
+ create: true
163
+ });
120
164
  const url = `${CALENDAR_BASE_URL}/calendars/${encodeURIComponent(config.calendarID)}/events`;
121
165
  const response = await fetchFn(url, {
122
166
  method: 'POST',
@@ -127,6 +171,17 @@ export async function createEvent(config) {
127
171
  body: JSON.stringify(event)
128
172
  });
129
173
  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
+ });
184
+ }
130
185
  const errorMessage = await parseGoogleError(response);
131
186
  throw new Error(`Google Calendar API error: ${errorMessage}`);
132
187
  }
package/lib/model.d.ts CHANGED
@@ -98,6 +98,9 @@ export declare const googleCalendarEventDefinition: {
98
98
  readonly recurringEventID: {
99
99
  readonly type: "string";
100
100
  };
101
+ readonly eventID: {
102
+ readonly type: "string";
103
+ };
101
104
  readonly calendarID: {
102
105
  readonly type: "string";
103
106
  };
@@ -130,7 +133,7 @@ export declare const googleCalendarEventDefinition: {
130
133
  readonly type: "string";
131
134
  readonly title: "SourceID";
132
135
  readonly minLength: 1;
133
- readonly maxLength: 500;
136
+ readonly maxLength: 1400;
134
137
  };
135
138
  readonly SourceURL: {
136
139
  readonly type: "string";
package/lib/model.js CHANGED
@@ -108,6 +108,9 @@ export const googleCalendarEventDefinition = {
108
108
  recurringEventID: {
109
109
  type: 'string'
110
110
  },
111
+ eventID: {
112
+ type: 'string'
113
+ },
111
114
  calendarID: {
112
115
  type: 'string'
113
116
  },
@@ -145,11 +148,13 @@ export const googleCalendarEventDefinition = {
145
148
  minLength: 1,
146
149
  maxLength: 50
147
150
  },
151
+ // A composite calendarID/eventID: calendar id (email-like, up to ~254) +
152
+ // '/' + a Google event id (up to 1024). 500 is too small.
148
153
  SourceID: {
149
154
  type: 'string',
150
155
  title: 'SourceID',
151
156
  minLength: 1,
152
- maxLength: 500
157
+ maxLength: 1400
153
158
  },
154
159
  SourceURL: urlSchema,
155
160
  DateTime: dateTimeSchema,
package/lib/provider.js CHANGED
@@ -31,7 +31,9 @@ function computeTimeMin(boundary) {
31
31
  // Parse PnD format (e.g. "P30D" = 30 days)
32
32
  const match = /^P(\d+)D$/i.exec(boundary.maxAge);
33
33
  if (match == null) return undefined;
34
- const days = Number.parseInt(match[1], 10);
34
+ const rawDays = match[1];
35
+ if (rawDays == null) return undefined;
36
+ const days = Number.parseInt(rawDays, 10);
35
37
  const date = new Date();
36
38
  date.setDate(date.getDate() - days);
37
39
  return date.toISOString();
@@ -104,8 +106,7 @@ export function createGoogleCalendarProvider(config) {
104
106
  // Phase 2: fetch events per calendar
105
107
  const calendarTokens = {};
106
108
  let eventBatchYielded = false;
107
- for(let i = 0; i < allCalendars.length; i++){
108
- const calendar = allCalendars[i];
109
+ for (const [i, calendar] of allCalendars.entries()){
109
110
  const isLastCalendar = i === allCalendars.length - 1;
110
111
  let eventsSyncToken;
111
112
  for await (const page of fetchEventsForCalendar({
@@ -135,9 +136,11 @@ export function createGoogleCalendarProvider(config) {
135
136
  yield {
136
137
  modelID: CALENDAR_EVENT_MODEL_ID,
137
138
  entities: activeItems.map((event)=>mapEventToEntity(event, calendar.id)),
139
+ // Match the composite identity mapEventToEntity assigns, so the
140
+ // tombstone lookup hits the calendar-namespaced document.
138
141
  deleted: cancelledItems.map((e)=>({
139
142
  sourceService: 'google-calendar',
140
- sourceID: e.id
143
+ sourceID: `${calendar.id}/${e.id}`
141
144
  })),
142
145
  hasMore: !(isLastCalendar && isLastPage),
143
146
  checkpoint
@@ -220,8 +223,8 @@ export function createGoogleCalendarProvider(config) {
220
223
  }))
221
224
  ];
222
225
  const updatedCalendarTokens = {};
223
- for(let i = 0; i < allCalendarIDsToSync.length; i++){
224
- const { id: calendarID, isNew } = allCalendarIDsToSync[i];
226
+ for (const [i, entry] of allCalendarIDsToSync.entries()){
227
+ const { id: calendarID, isNew } = entry;
225
228
  const isLastCalendar = i === allCalendarIDsToSync.length - 1;
226
229
  // Use stored syncToken for known calendars; use timeMin for new ones
227
230
  const storedToken = isNew ? undefined : prevCalendarTokens[calendarID]?.eventsSyncToken;
@@ -253,9 +256,11 @@ export function createGoogleCalendarProvider(config) {
253
256
  yield {
254
257
  modelID: CALENDAR_EVENT_MODEL_ID,
255
258
  entities: activeItems.map((event)=>mapEventToEntity(event, calendarID)),
259
+ // Match the composite identity mapEventToEntity assigns, so the
260
+ // tombstone lookup hits the calendar-namespaced document.
256
261
  deleted: cancelledItems.map((e)=>({
257
262
  sourceService: 'google-calendar',
258
- sourceID: e.id
263
+ sourceID: `${calendarID}/${e.id}`
259
264
  })),
260
265
  hasMore: !(isLastCalendar && isLastPage),
261
266
  checkpoint: newCheckpoint
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/connector-google-calendar",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "keywords": [],
5
5
  "license": "see LICENSE.md",
6
6
  "sideEffects": false,
@@ -15,12 +15,12 @@
15
15
  "LICENSE.md"
16
16
  ],
17
17
  "dependencies": {
18
- "@kubun/connector": "^0.15.0",
19
- "@kubun/credential-types": "^0.15.0",
20
- "@kubun/models": "^0.15.0",
21
- "@kubun/protocol": "^0.15.0",
22
- "@kubun/provider-google": "^0.15.0",
23
- "@kubun/scalars": "^0.15.0"
18
+ "@kubun/connector": "^0.16.0",
19
+ "@kubun/credential-types": "^0.16.0",
20
+ "@kubun/models": "^0.16.0",
21
+ "@kubun/protocol": "^0.16.1",
22
+ "@kubun/provider-google": "^0.16.0",
23
+ "@kubun/scalars": "^0.16.0"
24
24
  },
25
25
  "publishConfig": {
26
26
  "access": "public"
@@ -28,8 +28,8 @@
28
28
  "scripts": {
29
29
  "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
30
30
  "build:clean": "del lib",
31
- "build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
32
- "build:types": "tsc --emitDeclarationOnly --skipLibCheck",
31
+ "build:js": "del 'lib/**/*.js' && swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
32
+ "build:types": "del 'lib/**/*.d.ts' 'lib/**/*.d.ts.map' && tsc --emitDeclarationOnly --skipLibCheck",
33
33
  "build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
34
34
  "test": "pnpm run test:types && pnpm run test:unit",
35
35
  "test:types": "tsc --noEmit -p tsconfig.test.json",