@kubun/connector-google-calendar 0.17.1 → 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 +1 -0
- package/lib/provider.js +137 -22
- package/lib/target.d.ts +11 -0
- package/lib/target.js +68 -0
- package/package.json +11 -5
|
@@ -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,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type EntityRecord } from '@kubun/connector';
|
|
2
|
+
export { type ConnectorErrorKind, classifyHTTPStatus } from '@kubun/connector';
|
|
2
3
|
export declare function parseGoogleError(response: Response): Promise<string>;
|
|
3
4
|
export type GoogleCalendarResource = {
|
|
4
5
|
id: string;
|
|
@@ -23,6 +24,7 @@ export type GoogleEventDateTime = {
|
|
|
23
24
|
};
|
|
24
25
|
export type GoogleEventResource = {
|
|
25
26
|
id: string;
|
|
27
|
+
etag?: string;
|
|
26
28
|
summary?: string;
|
|
27
29
|
description?: string;
|
|
28
30
|
location?: string;
|
|
@@ -47,26 +49,42 @@ export declare function buildEventsListURL(params: {
|
|
|
47
49
|
pageToken?: string;
|
|
48
50
|
timeMin?: string;
|
|
49
51
|
}): string;
|
|
50
|
-
export declare function mapCalendarToEntity(calendar: GoogleCalendarResource): EntityRecord;
|
|
52
|
+
export declare function mapCalendarToEntity(calendar: GoogleCalendarResource, accountKey?: string): EntityRecord;
|
|
51
53
|
export declare function mapEventToEntity(event: GoogleEventResource, calendarID: string): EntityRecord;
|
|
52
54
|
export declare function buildEventFromParams(params: Record<string, unknown>, options?: {
|
|
53
55
|
create?: boolean;
|
|
56
|
+
eventID?: string;
|
|
54
57
|
}): Record<string, unknown>;
|
|
55
|
-
export declare function
|
|
58
|
+
export declare function getCalendarListEntry(config: {
|
|
56
59
|
accessToken: string;
|
|
57
60
|
calendarID: string;
|
|
58
61
|
fetch?: typeof globalThis.fetch;
|
|
59
62
|
}): Promise<GoogleCalendarResource>;
|
|
63
|
+
export declare function splitEventSourceID(sourceID: string): {
|
|
64
|
+
calendarID: string;
|
|
65
|
+
eventID: string;
|
|
66
|
+
};
|
|
60
67
|
export declare function getEvent(config: {
|
|
61
68
|
accessToken: string;
|
|
62
69
|
calendarID: string;
|
|
63
70
|
eventID: string;
|
|
64
71
|
fetch?: typeof globalThis.fetch;
|
|
65
72
|
}): Promise<EntityRecord>;
|
|
73
|
+
export declare function getEventWithETag(config: {
|
|
74
|
+
accessToken: string;
|
|
75
|
+
calendarID: string;
|
|
76
|
+
eventID: string;
|
|
77
|
+
fetch?: typeof globalThis.fetch;
|
|
78
|
+
}): Promise<{
|
|
79
|
+
record: EntityRecord;
|
|
80
|
+
etag?: string;
|
|
81
|
+
}>;
|
|
66
82
|
export declare function createEvent(config: {
|
|
67
83
|
accessToken: string;
|
|
68
84
|
calendarID: string;
|
|
69
85
|
params: Record<string, unknown>;
|
|
86
|
+
/** The provider event id to insert under (see `deriveEventID`); takes precedence over `params.id`. */
|
|
87
|
+
eventID?: string;
|
|
70
88
|
fetch?: typeof globalThis.fetch;
|
|
71
89
|
}): Promise<EntityRecord>;
|
|
72
90
|
export declare function updateEvent(config: {
|
|
@@ -74,5 +92,13 @@ export declare function updateEvent(config: {
|
|
|
74
92
|
calendarID: string;
|
|
75
93
|
eventID: string;
|
|
76
94
|
params: Record<string, unknown>;
|
|
95
|
+
/**
|
|
96
|
+
* The event's etag as last fetched, sent as If-Match: a change at Google
|
|
97
|
+
* since then fails the PATCH with a 412 (outcome `unknown`, resolved by
|
|
98
|
+
* recovery's reconcile read) instead of being overwritten. It is never
|
|
99
|
+
* re-read right before the write — that would guard nothing. Absent, the
|
|
100
|
+
* PATCH is unconditional.
|
|
101
|
+
*/
|
|
102
|
+
etag?: string | null;
|
|
77
103
|
fetch?: typeof globalThis.fetch;
|
|
78
104
|
}): Promise<EntityRecord>;
|
package/lib/api.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { connectorHTTPError } from '@kubun/connector';
|
|
2
|
+
import { GoogleCalendarEventConflict } from './errors.js';
|
|
3
|
+
import { eventHoldsIntent } from './intent.js';
|
|
4
|
+
export { classifyHTTPStatus } from '@kubun/connector';
|
|
1
5
|
const CALENDAR_BASE_URL = 'https://www.googleapis.com/calendar/v3';
|
|
2
6
|
export async function parseGoogleError(response) {
|
|
3
7
|
try {
|
|
@@ -7,6 +11,23 @@ export async function parseGoogleError(response) {
|
|
|
7
11
|
return `${response.status} ${response.statusText}`;
|
|
8
12
|
}
|
|
9
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Build the error thrown by an action's provider call, tagged with the HTTP
|
|
16
|
+
* status and its outcome classification so the caller can distinguish a
|
|
17
|
+
* definite rejection from an indeterminate failure.
|
|
18
|
+
*/ function googleAPIError(status, detail) {
|
|
19
|
+
return connectorHTTPError(`Google Calendar API error: ${detail}`, status);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A create that collided with an existing event it could not confirm as its
|
|
23
|
+
* own. Outcome-unknown, not a rejection: the event may be this create's own
|
|
24
|
+
* earlier effect, edited since. `code: 'CONFLICT'` and `sourceID` let
|
|
25
|
+
* write-back recovery reconcile against the event.
|
|
26
|
+
*/ function eventConflict(sourceID, cause) {
|
|
27
|
+
return new GoogleCalendarEventConflict(sourceID, cause != null ? {
|
|
28
|
+
cause
|
|
29
|
+
} : undefined);
|
|
30
|
+
}
|
|
10
31
|
export function buildCalendarListURL(params) {
|
|
11
32
|
const url = new URL(`${CALENDAR_BASE_URL}/users/me/calendarList`);
|
|
12
33
|
// Google rejects `showHidden=false` (and `showDeleted=false`) alongside a
|
|
@@ -37,7 +58,7 @@ export function buildEventsListURL(params) {
|
|
|
37
58
|
}
|
|
38
59
|
return url.toString();
|
|
39
60
|
}
|
|
40
|
-
export function mapCalendarToEntity(calendar) {
|
|
61
|
+
export function mapCalendarToEntity(calendar, accountKey) {
|
|
41
62
|
return {
|
|
42
63
|
sourceService: 'google-calendar',
|
|
43
64
|
sourceID: calendar.id,
|
|
@@ -46,7 +67,10 @@ export function mapCalendarToEntity(calendar) {
|
|
|
46
67
|
description: calendar.description,
|
|
47
68
|
timeZone: calendar.timeZone,
|
|
48
69
|
primary: calendar.primary ?? false,
|
|
49
|
-
backgroundColor: calendar.backgroundColor
|
|
70
|
+
backgroundColor: calendar.backgroundColor,
|
|
71
|
+
...accountKey != null ? {
|
|
72
|
+
accountKey
|
|
73
|
+
} : {}
|
|
50
74
|
};
|
|
51
75
|
}
|
|
52
76
|
// Google gives all-day events a bare calendar date (`YYYY-MM-DD`); the model's
|
|
@@ -81,17 +105,19 @@ export function mapEventToEntity(event, calendarID) {
|
|
|
81
105
|
calendar: {
|
|
82
106
|
sourceService: 'google-calendar',
|
|
83
107
|
sourceID: calendarID
|
|
84
|
-
}
|
|
108
|
+
},
|
|
109
|
+
etag: event.etag
|
|
85
110
|
};
|
|
86
111
|
}
|
|
87
112
|
export function buildEventFromParams(params, options) {
|
|
88
113
|
const event = {};
|
|
89
114
|
const allDay = params.allDay === true;
|
|
90
|
-
// A caller-
|
|
115
|
+
// A caller-chosen id makes a create idempotent (Google rejects a duplicate
|
|
91
116
|
// with 409). Only insert accepts an id; PATCH must never carry one, so gate it
|
|
92
117
|
// to the create path — the builder is shared with updateEvent.
|
|
93
|
-
|
|
94
|
-
|
|
118
|
+
const eventID = options?.eventID ?? params.id;
|
|
119
|
+
if (options?.create === true && eventID != null) {
|
|
120
|
+
event.id = eventID;
|
|
95
121
|
}
|
|
96
122
|
if (params.title != null) {
|
|
97
123
|
event.summary = params.title;
|
|
@@ -127,9 +153,13 @@ export function buildEventFromParams(params, options) {
|
|
|
127
153
|
}
|
|
128
154
|
return event;
|
|
129
155
|
}
|
|
130
|
-
|
|
156
|
+
// One calendar as the account's calendar list holds it (calendarList.get) —
|
|
157
|
+
// the same resource sync maps, with the per-account `primary` flag and
|
|
158
|
+
// colour. calendars.get carries neither, so a re-pull from it would clear
|
|
159
|
+
// `primary` and break resolving the 'primary' alias.
|
|
160
|
+
export async function getCalendarListEntry(config) {
|
|
131
161
|
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
132
|
-
const url = `${CALENDAR_BASE_URL}/
|
|
162
|
+
const url = `${CALENDAR_BASE_URL}/users/me/calendarList/${encodeURIComponent(config.calendarID)}`;
|
|
133
163
|
const response = await fetchFn(url, {
|
|
134
164
|
headers: {
|
|
135
165
|
Authorization: `Bearer ${config.accessToken}`
|
|
@@ -137,11 +167,23 @@ export async function getCalendar(config) {
|
|
|
137
167
|
});
|
|
138
168
|
if (!response.ok) {
|
|
139
169
|
const errorMessage = await parseGoogleError(response);
|
|
140
|
-
throw
|
|
170
|
+
throw googleAPIError(response.status, errorMessage);
|
|
141
171
|
}
|
|
142
172
|
return await response.json();
|
|
143
173
|
}
|
|
144
|
-
|
|
174
|
+
// A composite calendarID/eventID sourceID splits on the LAST '/': an eventID
|
|
175
|
+
// never contains one, but a calendarID (email-like) may.
|
|
176
|
+
export function splitEventSourceID(sourceID) {
|
|
177
|
+
const slash = sourceID.lastIndexOf('/');
|
|
178
|
+
if (slash <= 0 || slash === sourceID.length - 1) {
|
|
179
|
+
throw new Error(`Cannot recover eventID from sourceID "${sourceID}"`);
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
calendarID: sourceID.slice(0, slash),
|
|
183
|
+
eventID: sourceID.slice(slash + 1)
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
async function fetchEventResource(config) {
|
|
145
187
|
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
146
188
|
const url = `${CALENDAR_BASE_URL}/calendars/${encodeURIComponent(config.calendarID)}/events/${encodeURIComponent(config.eventID)}`;
|
|
147
189
|
const response = await fetchFn(url, {
|
|
@@ -151,16 +193,31 @@ export async function getEvent(config) {
|
|
|
151
193
|
});
|
|
152
194
|
if (!response.ok) {
|
|
153
195
|
const errorMessage = await parseGoogleError(response);
|
|
154
|
-
throw
|
|
196
|
+
throw googleAPIError(response.status, errorMessage);
|
|
155
197
|
}
|
|
156
|
-
|
|
198
|
+
return await response.json();
|
|
199
|
+
}
|
|
200
|
+
export async function getEvent(config) {
|
|
201
|
+
const data = await fetchEventResource(config);
|
|
157
202
|
return mapEventToEntity(data, config.calendarID);
|
|
158
203
|
}
|
|
204
|
+
// Same as getEvent, but also surfaces the provider's etag/revision — used by
|
|
205
|
+
// write-back recovery's reconcile read, which reports a providerETag
|
|
206
|
+
// alongside the record.
|
|
207
|
+
export async function getEventWithETag(config) {
|
|
208
|
+
const data = await fetchEventResource(config);
|
|
209
|
+
return {
|
|
210
|
+
record: mapEventToEntity(data, config.calendarID),
|
|
211
|
+
etag: data.etag
|
|
212
|
+
};
|
|
213
|
+
}
|
|
159
214
|
export async function createEvent(config) {
|
|
160
215
|
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
161
216
|
const event = buildEventFromParams(config.params, {
|
|
162
|
-
create: true
|
|
217
|
+
create: true,
|
|
218
|
+
eventID: config.eventID
|
|
163
219
|
});
|
|
220
|
+
const eventID = event.id;
|
|
164
221
|
const url = `${CALENDAR_BASE_URL}/calendars/${encodeURIComponent(config.calendarID)}/events`;
|
|
165
222
|
const response = await fetchFn(url, {
|
|
166
223
|
method: 'POST',
|
|
@@ -171,19 +228,34 @@ export async function createEvent(config) {
|
|
|
171
228
|
body: JSON.stringify(event)
|
|
172
229
|
});
|
|
173
230
|
if (!response.ok) {
|
|
174
|
-
// A 409 on a caller-
|
|
175
|
-
// is
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
231
|
+
// A 409 on a caller-chosen id means an event already exists under it. It
|
|
232
|
+
// is this create's own earlier effect (a retry after a lost response) only
|
|
233
|
+
// if it holds what this create writes; anything else is a conflict naming
|
|
234
|
+
// the existing event, for recovery to reconcile — never a success.
|
|
235
|
+
if (response.status === 409 && eventID != null) {
|
|
236
|
+
const sourceID = `${config.calendarID}/${eventID}`;
|
|
237
|
+
let existing;
|
|
238
|
+
try {
|
|
239
|
+
existing = await getEvent({
|
|
240
|
+
accessToken: config.accessToken,
|
|
241
|
+
calendarID: config.calendarID,
|
|
242
|
+
eventID,
|
|
243
|
+
fetch: config.fetch
|
|
244
|
+
});
|
|
245
|
+
} catch (err) {
|
|
246
|
+
throw eventConflict(sourceID, err);
|
|
247
|
+
}
|
|
248
|
+
if (!eventHoldsIntent({
|
|
249
|
+
op: 'create',
|
|
250
|
+
intent: config.params,
|
|
251
|
+
record: existing
|
|
252
|
+
})) {
|
|
253
|
+
throw eventConflict(sourceID);
|
|
254
|
+
}
|
|
255
|
+
return existing;
|
|
184
256
|
}
|
|
185
257
|
const errorMessage = await parseGoogleError(response);
|
|
186
|
-
throw
|
|
258
|
+
throw googleAPIError(response.status, errorMessage);
|
|
187
259
|
}
|
|
188
260
|
const data = await response.json();
|
|
189
261
|
return mapEventToEntity(data, config.calendarID);
|
|
@@ -196,13 +268,16 @@ export async function updateEvent(config) {
|
|
|
196
268
|
method: 'PATCH',
|
|
197
269
|
headers: {
|
|
198
270
|
Authorization: `Bearer ${config.accessToken}`,
|
|
199
|
-
'Content-Type': 'application/json'
|
|
271
|
+
'Content-Type': 'application/json',
|
|
272
|
+
...config.etag != null ? {
|
|
273
|
+
'If-Match': config.etag
|
|
274
|
+
} : {}
|
|
200
275
|
},
|
|
201
276
|
body: JSON.stringify(event)
|
|
202
277
|
});
|
|
203
278
|
if (!response.ok) {
|
|
204
279
|
const errorMessage = await parseGoogleError(response);
|
|
205
|
-
throw
|
|
280
|
+
throw googleAPIError(response.status, errorMessage);
|
|
206
281
|
}
|
|
207
282
|
const data = await response.json();
|
|
208
283
|
return mapEventToEntity(data, config.calendarID);
|
package/lib/connector.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { createGoogleCalendarActionHandler } from './actions.js';
|
|
2
|
+
import { canonicalizeEventIntent } from './intent.js';
|
|
2
3
|
import { googleCalendarClusters } from './model.js';
|
|
4
|
+
import { predictCreatedEventSourceID } from './predict.js';
|
|
3
5
|
import { createGoogleCalendarProvider } from './provider.js';
|
|
6
|
+
import { resolveCalendarTarget } from './target.js';
|
|
4
7
|
export function createGoogleCalendarConnector(options) {
|
|
5
8
|
return {
|
|
6
9
|
name: 'google-calendar',
|
|
@@ -42,7 +45,12 @@ export function createGoogleCalendarConnector(options) {
|
|
|
42
45
|
}
|
|
43
46
|
}
|
|
44
47
|
},
|
|
45
|
-
requiresCredential: true
|
|
48
|
+
requiresCredential: true,
|
|
49
|
+
// The provider event id is derived from the operation's deterministic
|
|
50
|
+
// event id (see deriveEventID), so a reissue inserts under the same id
|
|
51
|
+
// as the original attempt: a create that already landed answers 409
|
|
52
|
+
// instead of inserting a duplicate — safe to reissue.
|
|
53
|
+
recoveryClass: 'reissue-safe'
|
|
46
54
|
},
|
|
47
55
|
{
|
|
48
56
|
name: 'update',
|
|
@@ -76,13 +84,29 @@ export function createGoogleCalendarConnector(options) {
|
|
|
76
84
|
}
|
|
77
85
|
}
|
|
78
86
|
},
|
|
79
|
-
requiresCredential: true
|
|
87
|
+
requiresCredential: true,
|
|
88
|
+
// A sparse PATCH conditioned (If-Match) on the etag of the document
|
|
89
|
+
// the edit was made against, pinned on the operation at claim. A
|
|
90
|
+
// reissue after an unknown outcome would meet its own earlier write's
|
|
91
|
+
// new etag as a 412, and an unconditional one could clobber an
|
|
92
|
+
// intervening edit, so recovery reconciles (reads current provider
|
|
93
|
+
// state) rather than reissues.
|
|
94
|
+
recoveryClass: 'reconcile-only',
|
|
95
|
+
providerETagField: 'etag',
|
|
96
|
+
// An edit is only ever written against the version it was made on: an
|
|
97
|
+
// event with no stored etag (not synced yet) is refused before the
|
|
98
|
+
// claim rather than written unconditionally over a provider edit.
|
|
99
|
+
providerETagRequired: true
|
|
80
100
|
}
|
|
81
101
|
],
|
|
102
|
+
canonicalizeIntent: canonicalizeEventIntent,
|
|
103
|
+
resolveActionTarget: resolveCalendarTarget,
|
|
104
|
+
predictCreatedSourceID: predictCreatedEventSourceID,
|
|
82
105
|
actionHandler: (config)=>{
|
|
83
106
|
return createGoogleCalendarActionHandler({
|
|
84
107
|
credential: config.credential,
|
|
85
|
-
fetch: options?.fetch
|
|
108
|
+
fetch: options?.fetch,
|
|
109
|
+
mintFetchHLC: config.mintFetchHLC
|
|
86
110
|
});
|
|
87
111
|
},
|
|
88
112
|
auth: {
|
|
@@ -98,7 +122,8 @@ export function createGoogleCalendarConnector(options) {
|
|
|
98
122
|
return createGoogleCalendarProvider({
|
|
99
123
|
credential: config.credential,
|
|
100
124
|
boundary: config.boundary,
|
|
101
|
-
fetch: options?.fetch
|
|
125
|
+
fetch: options?.fetch,
|
|
126
|
+
mintFetchHLC: config.mintFetchHLC
|
|
102
127
|
});
|
|
103
128
|
}
|
|
104
129
|
};
|
package/lib/errors.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ConnectorError, type ConnectorErrorOptions } from '@kubun/connector';
|
|
2
|
+
/** A machine-readable failure code fixed for a given Google Calendar failure. */
|
|
3
|
+
export type GoogleCalendarErrorCode = 'GOOGLE_CALENDARS_NOT_SYNCED' | 'GOOGLE_CALENDAR_ID_REQUIRED' | 'GOOGLE_CALENDAR_ID_MISMATCH' | 'CONFLICT';
|
|
4
|
+
export type GoogleCalendarErrorOptions = ConnectorErrorOptions & {
|
|
5
|
+
code?: GoogleCalendarErrorCode;
|
|
6
|
+
};
|
|
7
|
+
/** A Google Calendar connector failure, classified the way `ConnectorError` is. */
|
|
8
|
+
export declare class GoogleCalendarError extends ConnectorError {
|
|
9
|
+
constructor(message: string, options?: GoogleCalendarErrorOptions);
|
|
10
|
+
get code(): GoogleCalendarErrorCode | undefined;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* A create that collided with an existing event it could not confirm as its
|
|
14
|
+
* own. Outcome-unknown, not a rejection: the event may be this create's own
|
|
15
|
+
* earlier effect, edited since. `sourceID` lets write-back recovery
|
|
16
|
+
* reconcile against the event.
|
|
17
|
+
*/
|
|
18
|
+
export declare class GoogleCalendarEventConflict extends GoogleCalendarError {
|
|
19
|
+
#private;
|
|
20
|
+
constructor(sourceID: string, options?: ErrorOptions);
|
|
21
|
+
get sourceID(): string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* A raw provider HTTP failure from a batch/list round trip (`fetchJSON` in
|
|
25
|
+
* `provider.ts`), carrying the response status so `fetchChanges` can detect
|
|
26
|
+
* an expired sync token (410) and fall back to a full resync.
|
|
27
|
+
*/
|
|
28
|
+
export declare class GoogleCalendarAPIError extends Error {
|
|
29
|
+
#private;
|
|
30
|
+
constructor(status: number, detail: string);
|
|
31
|
+
get status(): number;
|
|
32
|
+
}
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ConnectorError } from '@kubun/connector';
|
|
2
|
+
/** A Google Calendar connector failure, classified the way `ConnectorError` is. */ export class GoogleCalendarError extends ConnectorError {
|
|
3
|
+
constructor(message, options = {}){
|
|
4
|
+
super(message, options);
|
|
5
|
+
this.name = 'GoogleCalendarError';
|
|
6
|
+
}
|
|
7
|
+
get code() {
|
|
8
|
+
return super.code;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* A create that collided with an existing event it could not confirm as its
|
|
13
|
+
* own. Outcome-unknown, not a rejection: the event may be this create's own
|
|
14
|
+
* earlier effect, edited since. `sourceID` lets write-back recovery
|
|
15
|
+
* reconcile against the event.
|
|
16
|
+
*/ export class GoogleCalendarEventConflict extends GoogleCalendarError {
|
|
17
|
+
#sourceID;
|
|
18
|
+
constructor(sourceID, options){
|
|
19
|
+
super(`Google Calendar API error: an event already exists as "${sourceID}" and does not hold this create`, {
|
|
20
|
+
...options,
|
|
21
|
+
code: 'CONFLICT',
|
|
22
|
+
httpStatus: 409,
|
|
23
|
+
kind: 'unknown'
|
|
24
|
+
});
|
|
25
|
+
this.name = 'GoogleCalendarEventConflict';
|
|
26
|
+
this.#sourceID = sourceID;
|
|
27
|
+
}
|
|
28
|
+
get sourceID() {
|
|
29
|
+
return this.#sourceID;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A raw provider HTTP failure from a batch/list round trip (`fetchJSON` in
|
|
34
|
+
* `provider.ts`), carrying the response status so `fetchChanges` can detect
|
|
35
|
+
* an expired sync token (410) and fall back to a full resync.
|
|
36
|
+
*/ export class GoogleCalendarAPIError extends Error {
|
|
37
|
+
#status;
|
|
38
|
+
constructor(status, detail){
|
|
39
|
+
super(`Google Calendar API error: ${detail}`);
|
|
40
|
+
this.name = 'GoogleCalendarAPIError';
|
|
41
|
+
this.#status = status;
|
|
42
|
+
}
|
|
43
|
+
get status() {
|
|
44
|
+
return this.#status;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Google Calendar event id a create inserts under, derived from the
|
|
3
|
+
* operation's deterministic event id: the lowercase base32hex encoding (no
|
|
4
|
+
* padding) of a 32-byte digest of it, 52 characters — inside Google's allowed
|
|
5
|
+
* alphabet (`a-v`, `0-9`) and length (5 to 1024).
|
|
6
|
+
*
|
|
7
|
+
* Every attempt of the same operation derives the same id, so a create that is
|
|
8
|
+
* re-issued after its response was lost collides with its own earlier effect
|
|
9
|
+
* (a 409) instead of inserting a duplicate event. The mapping is one-way and
|
|
10
|
+
* versioned: changing it would re-key every create not yet confirmed.
|
|
11
|
+
*/
|
|
12
|
+
export declare function deriveEventID(deterministicEventID: string): string;
|
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
|
+
}
|
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
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,6 +69,17 @@ 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
|
|
83
85
|
// the 410 resync path, or a re-driven initial) instead of recomputing from
|
|
@@ -103,9 +105,14 @@ export function createGoogleCalendarProvider(config) {
|
|
|
103
105
|
}
|
|
104
106
|
}
|
|
105
107
|
// Yield calendar batch (no checkpoint yet — will be on the last event batch)
|
|
108
|
+
const calendarListToken = mintFetchHLC();
|
|
106
109
|
yield {
|
|
107
110
|
modelID: CALENDAR_MODEL_ID,
|
|
108
|
-
entities: allCalendars.map(
|
|
111
|
+
entities: allCalendars.map((c)=>({
|
|
112
|
+
present: true,
|
|
113
|
+
record: mapCalendarToEntity(c, accountKey),
|
|
114
|
+
sourceFetchHLC: calendarListToken
|
|
115
|
+
})),
|
|
109
116
|
hasMore: true
|
|
110
117
|
};
|
|
111
118
|
// Phase 2: fetch events per calendar
|
|
@@ -141,14 +148,20 @@ export function createGoogleCalendarProvider(config) {
|
|
|
141
148
|
// Split items: cancelled → deleted, others → entities
|
|
142
149
|
const activeItems = page.items.filter((e)=>e.status !== 'cancelled');
|
|
143
150
|
const cancelledItems = page.items.filter((e)=>e.status === 'cancelled');
|
|
151
|
+
const pageToken = mintFetchHLC();
|
|
144
152
|
yield {
|
|
145
153
|
modelID: CALENDAR_EVENT_MODEL_ID,
|
|
146
|
-
entities: activeItems.map((event)=>
|
|
154
|
+
entities: activeItems.map((event)=>({
|
|
155
|
+
present: true,
|
|
156
|
+
record: mapEventToEntity(event, calendar.id),
|
|
157
|
+
sourceFetchHLC: pageToken
|
|
158
|
+
})),
|
|
147
159
|
// Match the composite identity mapEventToEntity assigns, so the
|
|
148
160
|
// tombstone lookup hits the calendar-namespaced document.
|
|
149
161
|
deleted: cancelledItems.map((e)=>({
|
|
150
|
-
|
|
151
|
-
sourceID: `${calendar.id}/${e.id}
|
|
162
|
+
present: false,
|
|
163
|
+
sourceID: `${calendar.id}/${e.id}`,
|
|
164
|
+
sourceFetchHLC: pageToken
|
|
152
165
|
})),
|
|
153
166
|
hasMore: !(isLastCalendar && isLastPage),
|
|
154
167
|
checkpoint
|
|
@@ -202,12 +215,18 @@ export function createGoogleCalendarProvider(config) {
|
|
|
202
215
|
}
|
|
203
216
|
}
|
|
204
217
|
// Yield calendar batch (active + deletions)
|
|
218
|
+
const calendarListToken = mintFetchHLC();
|
|
205
219
|
yield {
|
|
206
220
|
modelID: CALENDAR_MODEL_ID,
|
|
207
|
-
entities: activeCalendars.map(
|
|
221
|
+
entities: activeCalendars.map((c)=>({
|
|
222
|
+
present: true,
|
|
223
|
+
record: mapCalendarToEntity(c, accountKey),
|
|
224
|
+
sourceFetchHLC: calendarListToken
|
|
225
|
+
})),
|
|
208
226
|
deleted: deletedCalendarIDs.map((id)=>({
|
|
209
|
-
|
|
210
|
-
sourceID: id
|
|
227
|
+
present: false,
|
|
228
|
+
sourceID: id,
|
|
229
|
+
sourceFetchHLC: calendarListToken
|
|
211
230
|
})),
|
|
212
231
|
hasMore: true
|
|
213
232
|
};
|
|
@@ -269,14 +288,20 @@ export function createGoogleCalendarProvider(config) {
|
|
|
269
288
|
// Split items: cancelled → deleted, others → entities
|
|
270
289
|
const activeItems = page.items.filter((e)=>e.status !== 'cancelled');
|
|
271
290
|
const cancelledItems = page.items.filter((e)=>e.status === 'cancelled');
|
|
291
|
+
const pageToken = mintFetchHLC();
|
|
272
292
|
yield {
|
|
273
293
|
modelID: CALENDAR_EVENT_MODEL_ID,
|
|
274
|
-
entities: activeItems.map((event)=>
|
|
294
|
+
entities: activeItems.map((event)=>({
|
|
295
|
+
present: true,
|
|
296
|
+
record: mapEventToEntity(event, calendarID),
|
|
297
|
+
sourceFetchHLC: pageToken
|
|
298
|
+
})),
|
|
275
299
|
// Match the composite identity mapEventToEntity assigns, so the
|
|
276
300
|
// tombstone lookup hits the calendar-namespaced document.
|
|
277
301
|
deleted: cancelledItems.map((e)=>({
|
|
278
|
-
|
|
279
|
-
sourceID: `${calendarID}/${e.id}
|
|
302
|
+
present: false,
|
|
303
|
+
sourceID: `${calendarID}/${e.id}`,
|
|
304
|
+
sourceFetchHLC: pageToken
|
|
280
305
|
})),
|
|
281
306
|
hasMore: !(isLastCalendar && isLastPage),
|
|
282
307
|
checkpoint: newCheckpoint
|
|
@@ -303,11 +328,101 @@ export function createGoogleCalendarProvider(config) {
|
|
|
303
328
|
};
|
|
304
329
|
}
|
|
305
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
|
+
}
|
|
306
419
|
return {
|
|
307
420
|
supportedStrategies: [
|
|
308
421
|
'on-demand'
|
|
309
422
|
],
|
|
310
423
|
fetchAll: fetchAllEntities,
|
|
424
|
+
fetchEntity,
|
|
425
|
+
reconcile,
|
|
311
426
|
async *fetchChanges (params) {
|
|
312
427
|
const checkpoint = params.checkpoint;
|
|
313
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"
|