@kubun/connector-google-contacts 0.17.0 → 0.17.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/actions.d.ts +1 -0
- package/lib/actions.js +30 -4
- package/lib/api.d.ts +26 -1
- package/lib/api.js +66 -44
- package/lib/connector.js +30 -4
- package/lib/errors.d.ts +21 -0
- package/lib/errors.js +25 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +2 -0
- package/lib/intent.d.ts +8 -0
- package/lib/intent.js +20 -0
- package/lib/model.d.ts +11 -5
- package/lib/model.js +26 -18
- package/lib/provider.d.ts +1 -0
- package/lib/provider.js +93 -27
- package/package.json +6 -5
package/lib/actions.d.ts
CHANGED
package/lib/actions.js
CHANGED
|
@@ -1,20 +1,46 @@
|
|
|
1
1
|
import { createContact, updateContact } from './api.js';
|
|
2
|
+
// The People API writes a contact's organization name and job title as one
|
|
3
|
+
// `organizations` entry, so a write that changes only one of them carries the
|
|
4
|
+
// other from the target document — sending one alone would clear the other.
|
|
5
|
+
function withOrganizationPair(params, existing) {
|
|
6
|
+
const changesOne = 'organization' in params !== 'jobTitle' in params;
|
|
7
|
+
if (!changesOne || existing == null) return params;
|
|
8
|
+
return 'organization' in params ? {
|
|
9
|
+
...params,
|
|
10
|
+
jobTitle: existing.jobTitle
|
|
11
|
+
} : {
|
|
12
|
+
...params,
|
|
13
|
+
organization: existing.organization
|
|
14
|
+
};
|
|
15
|
+
}
|
|
2
16
|
export function createGoogleContactsActionHandler(config) {
|
|
17
|
+
const mintActionFetchHLC = config.mintFetchHLC;
|
|
3
18
|
return {
|
|
4
19
|
async create (_model, params) {
|
|
5
|
-
|
|
20
|
+
const record = await createContact({
|
|
6
21
|
accessToken: config.credential.accessToken,
|
|
7
22
|
params,
|
|
8
23
|
fetch: config.fetch
|
|
9
24
|
});
|
|
25
|
+
return {
|
|
26
|
+
present: true,
|
|
27
|
+
record,
|
|
28
|
+
sourceFetchHLC: mintActionFetchHLC()
|
|
29
|
+
};
|
|
10
30
|
},
|
|
11
|
-
async update (_model, sourceID, params) {
|
|
12
|
-
|
|
31
|
+
async update (_model, sourceID, params, existing, context) {
|
|
32
|
+
const record = await updateContact({
|
|
13
33
|
accessToken: config.credential.accessToken,
|
|
14
34
|
sourceID,
|
|
15
|
-
params,
|
|
35
|
+
params: withOrganizationPair(params, existing),
|
|
36
|
+
etag: context?.providerETag,
|
|
16
37
|
fetch: config.fetch
|
|
17
38
|
});
|
|
39
|
+
return {
|
|
40
|
+
present: true,
|
|
41
|
+
record,
|
|
42
|
+
sourceFetchHLC: mintActionFetchHLC()
|
|
43
|
+
};
|
|
18
44
|
}
|
|
19
45
|
};
|
|
20
46
|
}
|
package/lib/api.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type EntityRecord } from '@kubun/connector';
|
|
2
|
+
import { type GoogleAPIClient } from '@kubun/provider-google';
|
|
2
3
|
export type GooglePersonResource = {
|
|
3
4
|
resourceName: string;
|
|
4
5
|
etag?: string;
|
|
@@ -20,7 +21,11 @@ export type GooglePersonResource = {
|
|
|
20
21
|
photos?: Array<{
|
|
21
22
|
url?: string;
|
|
22
23
|
}>;
|
|
24
|
+
metadata?: {
|
|
25
|
+
deleted?: boolean;
|
|
26
|
+
};
|
|
23
27
|
};
|
|
28
|
+
export declare function peopleAPIClient(accessToken: string, fetch?: typeof globalThis.fetch): GoogleAPIClient;
|
|
24
29
|
export type GoogleConnectionsResponse = {
|
|
25
30
|
connections?: Array<GooglePersonResource>;
|
|
26
31
|
nextPageToken?: string;
|
|
@@ -34,6 +39,19 @@ export declare function buildListURL(params: {
|
|
|
34
39
|
}): string;
|
|
35
40
|
export declare function mapPersonToEntity(person: GooglePersonResource): EntityRecord;
|
|
36
41
|
export declare function buildPersonFromParams(params: Record<string, unknown>): Partial<GooglePersonResource>;
|
|
42
|
+
export declare function getPerson(config: {
|
|
43
|
+
accessToken: string;
|
|
44
|
+
sourceID: string;
|
|
45
|
+
fetch?: typeof globalThis.fetch;
|
|
46
|
+
}): Promise<EntityRecord>;
|
|
47
|
+
export declare function getPersonWithETag(config: {
|
|
48
|
+
accessToken: string;
|
|
49
|
+
sourceID: string;
|
|
50
|
+
fetch?: typeof globalThis.fetch;
|
|
51
|
+
}): Promise<{
|
|
52
|
+
record: EntityRecord;
|
|
53
|
+
etag?: string;
|
|
54
|
+
}>;
|
|
37
55
|
export type CreateContactParams = {
|
|
38
56
|
accessToken: string;
|
|
39
57
|
params: Record<string, unknown>;
|
|
@@ -44,6 +62,13 @@ export type UpdateContactParams = {
|
|
|
44
62
|
accessToken: string;
|
|
45
63
|
sourceID: string;
|
|
46
64
|
params: Record<string, unknown>;
|
|
65
|
+
/**
|
|
66
|
+
* The contact's etag as last fetched. The People API writes a contact only
|
|
67
|
+
* against its etag and refuses (FAILED_PRECONDITION) a stale one, so a
|
|
68
|
+
* contact changed at Google since is never overwritten. It is never re-read
|
|
69
|
+
* right before the write — that would always match and guard nothing.
|
|
70
|
+
*/
|
|
71
|
+
etag?: string | null;
|
|
47
72
|
fetch?: typeof globalThis.fetch;
|
|
48
73
|
};
|
|
49
74
|
export declare function updateContact(options: UpdateContactParams): Promise<EntityRecord>;
|
package/lib/api.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { classifyHTTPStatus, connectorHTTPError } from '@kubun/connector';
|
|
2
|
+
import { createGoogleAPIClient } from '@kubun/provider-google';
|
|
3
|
+
import { ContactEtagRequired, GoogleContactsError } from './errors.js';
|
|
1
4
|
const PERSON_FIELDS = 'names,emailAddresses,phoneNumbers,organizations,photos';
|
|
2
5
|
const CONTACTS_BASE_URL = 'https://people.googleapis.com/v1';
|
|
3
6
|
// A Google People API resource name is `people/{id}`. A sourceID reaches here
|
|
@@ -10,13 +13,23 @@ function assertValidSourceID(sourceID) {
|
|
|
10
13
|
throw new Error(`Invalid contact sourceID: ${JSON.stringify(sourceID)}`);
|
|
11
14
|
}
|
|
12
15
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
16
|
+
export function peopleAPIClient(accessToken, fetch) {
|
|
17
|
+
return createGoogleAPIClient({
|
|
18
|
+
api: 'Google People API',
|
|
19
|
+
accessToken,
|
|
20
|
+
fetch,
|
|
21
|
+
toError: ({ httpStatus, message })=>connectorHTTPError(message, httpStatus)
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
// A write answered with FAILED_PRECONDITION (a 400) was refused because the
|
|
25
|
+
// contact changed at Google since the etag it was conditioned on — the People
|
|
26
|
+
// API's form of a 412. Like a 412 it is outcome-unknown, never a rejection:
|
|
27
|
+
// recovery reconciles against the contact's current state.
|
|
28
|
+
function googlePeopleWriteError(failure) {
|
|
29
|
+
return new GoogleContactsError(failure.message, {
|
|
30
|
+
httpStatus: failure.httpStatus,
|
|
31
|
+
kind: failure.googleStatus === 'FAILED_PRECONDITION' ? 'unknown' : classifyHTTPStatus(failure.httpStatus)
|
|
32
|
+
});
|
|
20
33
|
}
|
|
21
34
|
export function buildListURL(params) {
|
|
22
35
|
const url = new URL(`${CONTACTS_BASE_URL}/people/me/connections`);
|
|
@@ -43,7 +56,8 @@ export function mapPersonToEntity(person) {
|
|
|
43
56
|
organization: person.organizations?.[0]?.name ?? undefined,
|
|
44
57
|
jobTitle: person.organizations?.[0]?.title ?? undefined,
|
|
45
58
|
photoURL: person.photos?.[0]?.url ?? undefined,
|
|
46
|
-
sourceURL: `https://contacts.google.com/person/${person.resourceName.replace('people/', '')}
|
|
59
|
+
sourceURL: `https://contacts.google.com/person/${person.resourceName.replace('people/', '')}`,
|
|
60
|
+
etag: person.etag
|
|
47
61
|
};
|
|
48
62
|
}
|
|
49
63
|
export function buildPersonFromParams(params) {
|
|
@@ -83,59 +97,67 @@ export function buildPersonFromParams(params) {
|
|
|
83
97
|
}
|
|
84
98
|
return person;
|
|
85
99
|
}
|
|
100
|
+
// Raw people.get, shared by getPerson/getPersonWithETag (fetchEntity/
|
|
101
|
+
// reconcile's targeted read) — a single choke point for sourceID validation
|
|
102
|
+
// and 404 classification.
|
|
103
|
+
async function fetchPersonResource(config) {
|
|
104
|
+
assertValidSourceID(config.sourceID);
|
|
105
|
+
const url = `${CONTACTS_BASE_URL}/${config.sourceID}?personFields=${PERSON_FIELDS}`;
|
|
106
|
+
return peopleAPIClient(config.accessToken, config.fetch).get(url);
|
|
107
|
+
}
|
|
108
|
+
// Targeted single-contact fetch (anti-entropy repair verification / write-back
|
|
109
|
+
// recovery's fetchEntity). A 404 here is the deletion signal: fetchOrAbsent
|
|
110
|
+
// turns it into a `present: false` result instead of a throw.
|
|
111
|
+
export async function getPerson(config) {
|
|
112
|
+
const data = await fetchPersonResource(config);
|
|
113
|
+
return mapPersonToEntity(data);
|
|
114
|
+
}
|
|
115
|
+
// Same as getPerson, but also surfaces the provider's etag — used by
|
|
116
|
+
// write-back recovery's reconcile read, which reports a providerETag
|
|
117
|
+
// alongside the record.
|
|
118
|
+
export async function getPersonWithETag(config) {
|
|
119
|
+
const data = await fetchPersonResource(config);
|
|
120
|
+
return {
|
|
121
|
+
record: mapPersonToEntity(data),
|
|
122
|
+
etag: data.etag
|
|
123
|
+
};
|
|
124
|
+
}
|
|
86
125
|
export async function createContact(options) {
|
|
87
|
-
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
88
126
|
const person = buildPersonFromParams(options.params);
|
|
89
|
-
const
|
|
127
|
+
const created = await peopleAPIClient(options.accessToken, options.fetch).send(`${CONTACTS_BASE_URL}/people:createContact?personFields=${PERSON_FIELDS}`, {
|
|
90
128
|
method: 'POST',
|
|
91
|
-
|
|
92
|
-
Authorization: `Bearer ${options.accessToken}`,
|
|
93
|
-
'Content-Type': 'application/json'
|
|
94
|
-
},
|
|
95
|
-
body: JSON.stringify(person)
|
|
129
|
+
body: person
|
|
96
130
|
});
|
|
97
|
-
if (!response.ok) {
|
|
98
|
-
const errorMessage = await parseGoogleError(response);
|
|
99
|
-
throw new Error(`Google People API error: ${errorMessage}`);
|
|
100
|
-
}
|
|
101
|
-
const created = await response.json();
|
|
102
131
|
return mapPersonToEntity(created);
|
|
103
132
|
}
|
|
133
|
+
function contactEtagRequired() {
|
|
134
|
+
return new ContactEtagRequired();
|
|
135
|
+
}
|
|
104
136
|
export async function updateContact(options) {
|
|
105
137
|
assertValidSourceID(options.sourceID);
|
|
106
|
-
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
headers: {
|
|
111
|
-
Authorization: `Bearer ${options.accessToken}`
|
|
112
|
-
}
|
|
113
|
-
});
|
|
114
|
-
if (!getResponse.ok) {
|
|
115
|
-
const errorMessage = await parseGoogleError(getResponse);
|
|
116
|
-
throw new Error(`Google People API error: ${errorMessage}`);
|
|
117
|
-
}
|
|
118
|
-
const current = await getResponse.json();
|
|
138
|
+
// The People API requires an etag on every update. Without the stored one
|
|
139
|
+
// the only way to get one is a fresh read, which would overwrite a change
|
|
140
|
+
// made at Google since the edit: refuse before any request instead.
|
|
141
|
+
if (options.etag == null || options.etag === '') throw contactEtagRequired();
|
|
119
142
|
const person = buildPersonFromParams(options.params);
|
|
120
|
-
person.etag =
|
|
143
|
+
person.etag = options.etag;
|
|
121
144
|
const updatePersonFields = [];
|
|
122
145
|
if (person.names != null) updatePersonFields.push('names');
|
|
123
146
|
if (person.emailAddresses != null) updatePersonFields.push('emailAddresses');
|
|
124
147
|
if (person.phoneNumbers != null) updatePersonFields.push('phoneNumbers');
|
|
125
148
|
if (person.organizations != null) updatePersonFields.push('organizations');
|
|
126
149
|
const url = `${CONTACTS_BASE_URL}/${options.sourceID}:updateContact?updatePersonFields=${updatePersonFields.join(',')}&personFields=${PERSON_FIELDS}`;
|
|
127
|
-
const
|
|
150
|
+
const client = createGoogleAPIClient({
|
|
151
|
+
api: 'Google People API',
|
|
152
|
+
accessToken: options.accessToken,
|
|
153
|
+
fetch: options.fetch,
|
|
154
|
+
toError: googlePeopleWriteError
|
|
155
|
+
});
|
|
156
|
+
const response = await client.request(url, {
|
|
128
157
|
method: 'PATCH',
|
|
129
|
-
|
|
130
|
-
Authorization: `Bearer ${options.accessToken}`,
|
|
131
|
-
'Content-Type': 'application/json'
|
|
132
|
-
},
|
|
133
|
-
body: JSON.stringify(person)
|
|
158
|
+
body: person
|
|
134
159
|
});
|
|
135
|
-
if (!response.ok)
|
|
136
|
-
const errorMessage = await parseGoogleError(response);
|
|
137
|
-
throw new Error(`Google People API error: ${errorMessage}`);
|
|
138
|
-
}
|
|
160
|
+
if (!response.ok) return client.fail(response);
|
|
139
161
|
const updated = await response.json();
|
|
140
162
|
return mapPersonToEntity(updated);
|
|
141
163
|
}
|
package/lib/connector.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createGoogleContactsActionHandler } from './actions.js';
|
|
2
|
+
import { canonicalizeContactIntent } from './intent.js';
|
|
2
3
|
import { googleContactsClusters } from './model.js';
|
|
3
4
|
import { createGoogleContactsProvider } from './provider.js';
|
|
4
5
|
export function createGoogleContactsConnector(options) {
|
|
@@ -46,7 +47,14 @@ export function createGoogleContactsConnector(options) {
|
|
|
46
47
|
required: [
|
|
47
48
|
'displayName'
|
|
48
49
|
]
|
|
49
|
-
}
|
|
50
|
+
},
|
|
51
|
+
// Google assigns the resourceName (people/{id}) — there is no
|
|
52
|
+
// caller-supplied deterministic id to key a safe reissue on, unlike
|
|
53
|
+
// the google-calendar connector's create. A blind retry after an
|
|
54
|
+
// outcome-unknown failure could create a duplicate contact, so
|
|
55
|
+
// recovery must reconcile rather than reissue; this action is never
|
|
56
|
+
// re-dispatched on recovery.
|
|
57
|
+
recoveryClass: 'reconcile-only'
|
|
50
58
|
},
|
|
51
59
|
{
|
|
52
60
|
name: 'update',
|
|
@@ -78,19 +86,37 @@ export function createGoogleContactsConnector(options) {
|
|
|
78
86
|
required: [
|
|
79
87
|
'sourceID'
|
|
80
88
|
]
|
|
81
|
-
}
|
|
89
|
+
},
|
|
90
|
+
// Written against the etag the contact was fetched with, pinned on the
|
|
91
|
+
// operation at claim: a contact changed at Google since is refused
|
|
92
|
+
// (FAILED_PRECONDITION), never overwritten. A reissue would meet its
|
|
93
|
+
// own earlier write's new etag, so recovery reconciles (reads current
|
|
94
|
+
// provider state) rather than reissues.
|
|
95
|
+
recoveryClass: 'reconcile-only',
|
|
96
|
+
providerETagField: 'etag',
|
|
97
|
+
// The People API writes a contact only against its etag.
|
|
98
|
+
providerETagRequired: true
|
|
82
99
|
}
|
|
83
100
|
],
|
|
101
|
+
canonicalizeIntent: canonicalizeContactIntent,
|
|
102
|
+
// A contact write lands in the account's own contacts: its target is the
|
|
103
|
+
// account (`people/me`), already pinned by the operation's account key.
|
|
104
|
+
resolveActionTarget: async ({ input })=>({
|
|
105
|
+
target: 'people/me',
|
|
106
|
+
input
|
|
107
|
+
}),
|
|
84
108
|
actionHandler: (config)=>{
|
|
85
109
|
return createGoogleContactsActionHandler({
|
|
86
110
|
credential: config.credential,
|
|
87
|
-
fetch: options?.fetch
|
|
111
|
+
fetch: options?.fetch,
|
|
112
|
+
mintFetchHLC: config.mintFetchHLC
|
|
88
113
|
});
|
|
89
114
|
},
|
|
90
115
|
serverProvider: (config)=>{
|
|
91
116
|
return createGoogleContactsProvider({
|
|
92
117
|
credential: config.credential,
|
|
93
|
-
fetch: options?.fetch
|
|
118
|
+
fetch: options?.fetch,
|
|
119
|
+
mintFetchHLC: config.mintFetchHLC
|
|
94
120
|
});
|
|
95
121
|
}
|
|
96
122
|
};
|
package/lib/errors.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ConnectorError, type ConnectorErrorOptions } from '@kubun/connector';
|
|
2
|
+
/** A machine-readable failure code fixed for a given Google Contacts failure. */
|
|
3
|
+
export type GoogleContactsErrorCode = 'CONTACT_ETAG_REQUIRED';
|
|
4
|
+
export type GoogleContactsErrorOptions = ConnectorErrorOptions & {
|
|
5
|
+
code?: GoogleContactsErrorCode;
|
|
6
|
+
};
|
|
7
|
+
/** A Google Contacts connector failure, classified the way `ConnectorError` is. */
|
|
8
|
+
export declare class GoogleContactsError extends ConnectorError {
|
|
9
|
+
constructor(message: string, options?: GoogleContactsErrorOptions);
|
|
10
|
+
get code(): GoogleContactsErrorCode | undefined;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* An update was attempted against a contact with no stored etag to write
|
|
14
|
+
* against. The People API requires an etag on every update; the only way to
|
|
15
|
+
* get one for a contact synced before this field existed is a fresh read,
|
|
16
|
+
* which would overwrite a change made at Google since the edit — so the
|
|
17
|
+
* write is refused before any request instead.
|
|
18
|
+
*/
|
|
19
|
+
export declare class ContactEtagRequired extends GoogleContactsError {
|
|
20
|
+
constructor();
|
|
21
|
+
}
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ConnectorError } from '@kubun/connector';
|
|
2
|
+
/** A Google Contacts connector failure, classified the way `ConnectorError` is. */ export class GoogleContactsError extends ConnectorError {
|
|
3
|
+
constructor(message, options = {}){
|
|
4
|
+
super(message, options);
|
|
5
|
+
this.name = 'GoogleContactsError';
|
|
6
|
+
}
|
|
7
|
+
get code() {
|
|
8
|
+
return super.code;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* An update was attempted against a contact with no stored etag to write
|
|
13
|
+
* against. The People API requires an etag on every update; the only way to
|
|
14
|
+
* get one for a contact synced before this field existed is a fresh read,
|
|
15
|
+
* which would overwrite a change made at Google since the edit — so the
|
|
16
|
+
* write is refused before any request instead.
|
|
17
|
+
*/ export class ContactEtagRequired extends GoogleContactsError {
|
|
18
|
+
constructor(){
|
|
19
|
+
super('CONTACT_ETAG_REQUIRED: the contact has no stored etag to write against; sync contacts before editing it', {
|
|
20
|
+
code: 'CONTACT_ETAG_REQUIRED',
|
|
21
|
+
kind: 'rejected'
|
|
22
|
+
});
|
|
23
|
+
this.name = 'ContactEtagRequired';
|
|
24
|
+
}
|
|
25
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { createGoogleContactsActionHandler, type GoogleContactsActionHandlerConfig, } from './actions.js';
|
|
2
2
|
export { buildListURL, buildPersonFromParams, type CreateContactParams, createContact, type GoogleConnectionsResponse, type GooglePersonResource, mapPersonToEntity, type UpdateContactParams, updateContact, } from './api.js';
|
|
3
3
|
export { type CreateGoogleContactsConnectorOptions, createGoogleContactsConnector, } from './connector.js';
|
|
4
|
+
export { ContactEtagRequired, GoogleContactsError, type GoogleContactsErrorCode, type GoogleContactsErrorOptions, } from './errors.js';
|
|
5
|
+
export { canonicalizeContactIntent } from './intent.js';
|
|
4
6
|
export { googleContactDefinition, googleContactsClusters } from './model.js';
|
|
5
7
|
export { createGoogleContactsProvider, type GoogleContactsProviderConfig } from './provider.js';
|
package/lib/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { createGoogleContactsActionHandler } from './actions.js';
|
|
2
2
|
export { buildListURL, buildPersonFromParams, createContact, mapPersonToEntity, updateContact } from './api.js';
|
|
3
3
|
export { createGoogleContactsConnector } from './connector.js';
|
|
4
|
+
export { ContactEtagRequired, GoogleContactsError } from './errors.js';
|
|
5
|
+
export { canonicalizeContactIntent } from './intent.js';
|
|
4
6
|
export { googleContactDefinition, googleContactsClusters } from './model.js';
|
|
5
7
|
export { createGoogleContactsProvider } from './provider.js';
|
package/lib/intent.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { IntentCanonicalizer } from '@kubun/connector';
|
|
2
|
+
/**
|
|
3
|
+
* The Contacts intent canonicalizer (see `IntentCanonicalizer`). The People
|
|
4
|
+
* API keeps no empty value — a written empty string comes back as an absent
|
|
5
|
+
* field — so an empty string compares equal to an absent one, on both sides.
|
|
6
|
+
* Nothing else is folded: a value is never taken for a different one.
|
|
7
|
+
*/
|
|
8
|
+
export declare const canonicalizeContactIntent: IntentCanonicalizer;
|
package/lib/intent.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { googleContactDefinition } from './model.js';
|
|
2
|
+
/**
|
|
3
|
+
* The Contacts intent canonicalizer (see `IntentCanonicalizer`). The People
|
|
4
|
+
* API keeps no empty value — a written empty string comes back as an absent
|
|
5
|
+
* field — so an empty string compares equal to an absent one, on both sides.
|
|
6
|
+
* Nothing else is folded: a value is never taken for a different one.
|
|
7
|
+
*/ export const canonicalizeContactIntent = ({ model, intent, record })=>{
|
|
8
|
+
if (model !== googleContactDefinition.name) return {
|
|
9
|
+
intent,
|
|
10
|
+
record
|
|
11
|
+
};
|
|
12
|
+
const withoutEmpty = (values)=>Object.fromEntries(Object.entries(values).map(([field, value])=>[
|
|
13
|
+
field,
|
|
14
|
+
value === '' ? undefined : value
|
|
15
|
+
]));
|
|
16
|
+
return {
|
|
17
|
+
intent: withoutEmpty(intent),
|
|
18
|
+
record: withoutEmpty(record)
|
|
19
|
+
};
|
|
20
|
+
};
|
package/lib/model.d.ts
CHANGED
|
@@ -35,10 +35,18 @@ export declare const googleContactDefinition: {
|
|
|
35
35
|
readonly photoURL: {
|
|
36
36
|
readonly $ref: "#/definitions/PhotoURL";
|
|
37
37
|
};
|
|
38
|
+
readonly etag: {
|
|
39
|
+
readonly type: "string";
|
|
40
|
+
};
|
|
38
41
|
};
|
|
39
42
|
readonly required: ["sourceService", "sourceID", "lastSyncedAt"];
|
|
40
43
|
readonly additionalProperties: false;
|
|
41
44
|
readonly definitions: {
|
|
45
|
+
readonly PhotoURL: {
|
|
46
|
+
readonly type: "string";
|
|
47
|
+
readonly format: "uri";
|
|
48
|
+
readonly title: "URL";
|
|
49
|
+
};
|
|
42
50
|
readonly SourceService: {
|
|
43
51
|
readonly type: "string";
|
|
44
52
|
readonly title: "SourceService";
|
|
@@ -56,14 +64,12 @@ export declare const googleContactDefinition: {
|
|
|
56
64
|
readonly format: "uri";
|
|
57
65
|
readonly title: "URL";
|
|
58
66
|
};
|
|
59
|
-
readonly PhotoURL: {
|
|
60
|
-
readonly type: "string";
|
|
61
|
-
readonly format: "uri";
|
|
62
|
-
readonly title: "URL";
|
|
63
|
-
};
|
|
64
67
|
readonly DateTime: import("json-schema-typed").JSONSchema.String;
|
|
65
68
|
};
|
|
66
69
|
};
|
|
67
70
|
readonly fieldsMeta: {};
|
|
71
|
+
readonly providerMergePolicy: {
|
|
72
|
+
readonly group: ["displayName", "sourceService", "sourceID", "sourceURL", "email", "phoneNumber", "organization", "jobTitle", "photoURL", "etag"];
|
|
73
|
+
};
|
|
68
74
|
};
|
|
69
75
|
export declare const googleContactsClusters: 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 {
|
|
4
|
+
import { urlSchema } from '@kubun/scalars';
|
|
5
5
|
export const googleContactDefinition = {
|
|
6
6
|
name: 'GoogleContact',
|
|
7
7
|
behavior: 'default',
|
|
@@ -41,6 +41,9 @@ export const googleContactDefinition = {
|
|
|
41
41
|
},
|
|
42
42
|
photoURL: {
|
|
43
43
|
$ref: '#/definitions/PhotoURL'
|
|
44
|
+
},
|
|
45
|
+
etag: {
|
|
46
|
+
type: 'string'
|
|
44
47
|
}
|
|
45
48
|
},
|
|
46
49
|
required: [
|
|
@@ -50,24 +53,29 @@ export const googleContactDefinition = {
|
|
|
50
53
|
],
|
|
51
54
|
additionalProperties: false,
|
|
52
55
|
definitions: {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
title: 'SourceService',
|
|
56
|
-
minLength: 1,
|
|
57
|
-
maxLength: 50
|
|
58
|
-
},
|
|
59
|
-
SourceID: {
|
|
60
|
-
type: 'string',
|
|
61
|
-
title: 'SourceID',
|
|
62
|
-
minLength: 1,
|
|
63
|
-
maxLength: 500
|
|
64
|
-
},
|
|
65
|
-
SourceURL: urlSchema,
|
|
66
|
-
PhotoURL: urlSchema,
|
|
67
|
-
DateTime: dateTimeSchema
|
|
56
|
+
...externalEntityDefinition.schema.definitions,
|
|
57
|
+
PhotoURL: urlSchema
|
|
68
58
|
}
|
|
69
59
|
},
|
|
70
|
-
fieldsMeta: {}
|
|
60
|
+
fieldsMeta: {},
|
|
61
|
+
// lastSyncedAt is a device-local sync-bookkeeping annotation, not fetched
|
|
62
|
+
// provider content, so it stays outside the group.
|
|
63
|
+
providerMergePolicy: {
|
|
64
|
+
group: [
|
|
65
|
+
'displayName',
|
|
66
|
+
'sourceService',
|
|
67
|
+
'sourceID',
|
|
68
|
+
'sourceURL',
|
|
69
|
+
'email',
|
|
70
|
+
'phoneNumber',
|
|
71
|
+
'organization',
|
|
72
|
+
'jobTitle',
|
|
73
|
+
'photoURL',
|
|
74
|
+
// The provider revision of this state: an update is written against it,
|
|
75
|
+
// so it must travel with the fields it versions.
|
|
76
|
+
'etag'
|
|
77
|
+
]
|
|
78
|
+
}
|
|
71
79
|
};
|
|
72
80
|
const builder = new GraphBuilder({
|
|
73
81
|
externalClusters: {
|
package/lib/provider.d.ts
CHANGED
|
@@ -3,5 +3,6 @@ import type { Credential } from '@kubun/credential-types';
|
|
|
3
3
|
export type GoogleContactsProviderConfig = {
|
|
4
4
|
credential: Credential;
|
|
5
5
|
fetch?: typeof globalThis.fetch;
|
|
6
|
+
mintFetchHLC: () => string;
|
|
6
7
|
};
|
|
7
8
|
export declare function createGoogleContactsProvider(config: GoogleContactsProviderConfig): ServerDataProvider;
|
package/lib/provider.js
CHANGED
|
@@ -1,24 +1,19 @@
|
|
|
1
|
-
import { resolveModelID } from '@kubun/connector';
|
|
2
|
-
import { buildListURL, mapPersonToEntity } from './api.js';
|
|
1
|
+
import { fetchOrAbsent, resolveModelID } from '@kubun/connector';
|
|
2
|
+
import { buildListURL, getPerson, getPersonWithETag, mapPersonToEntity, peopleAPIClient } from './api.js';
|
|
3
3
|
import { googleContactsClusters } from './model.js';
|
|
4
4
|
const GOOGLE_CONTACT_MODEL_ID = resolveModelID(googleContactsClusters, 'GoogleContact');
|
|
5
5
|
export function createGoogleContactsProvider(config) {
|
|
6
6
|
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
7
|
+
const client = peopleAPIClient(config.credential.accessToken, fetchFn);
|
|
8
|
+
const mintFetchHLC = config.mintFetchHLC;
|
|
7
9
|
async function fetchPage(params) {
|
|
8
10
|
const url = buildListURL({
|
|
9
11
|
pageToken: params.pageToken,
|
|
10
12
|
syncToken: params.syncToken
|
|
11
13
|
});
|
|
12
|
-
|
|
13
|
-
headers: {
|
|
14
|
-
Authorization: `Bearer ${config.credential.accessToken}`
|
|
15
|
-
},
|
|
14
|
+
return client.get(url, {
|
|
16
15
|
signal: params.signal
|
|
17
16
|
});
|
|
18
|
-
if (!response.ok) {
|
|
19
|
-
throw new Error(`Google People API error: ${response.status} ${response.statusText}`);
|
|
20
|
-
}
|
|
21
|
-
return await response.json();
|
|
22
17
|
}
|
|
23
18
|
async function* fetchAllEntities(params) {
|
|
24
19
|
let pageToken;
|
|
@@ -28,7 +23,15 @@ export function createGoogleContactsProvider(config) {
|
|
|
28
23
|
pageToken,
|
|
29
24
|
signal: params.signal
|
|
30
25
|
});
|
|
31
|
-
const
|
|
26
|
+
const sourceFetchHLC = mintFetchHLC();
|
|
27
|
+
// A first sync never carries a syncToken, so Google never returns a
|
|
28
|
+
// deleted tombstone on this path — deletion detection is
|
|
29
|
+
// incremental-only (see fetchIncrementalEntities).
|
|
30
|
+
const entities = (data.connections ?? []).map((person)=>({
|
|
31
|
+
present: true,
|
|
32
|
+
record: mapPersonToEntity(person),
|
|
33
|
+
sourceFetchHLC
|
|
34
|
+
}));
|
|
32
35
|
pageToken = data.nextPageToken;
|
|
33
36
|
syncToken = data.nextSyncToken;
|
|
34
37
|
yield {
|
|
@@ -39,33 +42,96 @@ export function createGoogleContactsProvider(config) {
|
|
|
39
42
|
};
|
|
40
43
|
}while (pageToken != null)
|
|
41
44
|
}
|
|
45
|
+
async function* fetchIncrementalEntities(params) {
|
|
46
|
+
const syncToken = params.checkpoint;
|
|
47
|
+
let pageToken;
|
|
48
|
+
do {
|
|
49
|
+
const data = await fetchPage({
|
|
50
|
+
pageToken,
|
|
51
|
+
syncToken: pageToken != null ? undefined : syncToken,
|
|
52
|
+
signal: params.signal
|
|
53
|
+
});
|
|
54
|
+
const sourceFetchHLC = mintFetchHLC();
|
|
55
|
+
const connections = data.connections ?? [];
|
|
56
|
+
// A removed contact comes back with only resourceName + metadata.deleted
|
|
57
|
+
// populated (all content fields absent) — split it into the deletion
|
|
58
|
+
// batch instead of mapping it as content.
|
|
59
|
+
const activePersons = connections.filter((p)=>p.metadata?.deleted !== true);
|
|
60
|
+
const deletedPersons = connections.filter((p)=>p.metadata?.deleted === true);
|
|
61
|
+
pageToken = data.nextPageToken;
|
|
62
|
+
yield {
|
|
63
|
+
modelID: GOOGLE_CONTACT_MODEL_ID,
|
|
64
|
+
entities: activePersons.map((person)=>({
|
|
65
|
+
present: true,
|
|
66
|
+
record: mapPersonToEntity(person),
|
|
67
|
+
sourceFetchHLC
|
|
68
|
+
})),
|
|
69
|
+
deleted: deletedPersons.map((person)=>({
|
|
70
|
+
present: false,
|
|
71
|
+
sourceID: person.resourceName,
|
|
72
|
+
sourceFetchHLC
|
|
73
|
+
})),
|
|
74
|
+
hasMore: pageToken != null,
|
|
75
|
+
checkpoint: data.nextSyncToken
|
|
76
|
+
};
|
|
77
|
+
}while (pageToken != null)
|
|
78
|
+
}
|
|
79
|
+
// Targeted single-entity fetch (anti-entropy repair verification). Mints its
|
|
80
|
+
// own fetch-order token at request start, independent of any batch token
|
|
81
|
+
// (see ServerProviderFactory.mintFetchHLC's doc).
|
|
82
|
+
async function fetchEntity(_model, sourceID) {
|
|
83
|
+
const sourceFetchHLC = mintFetchHLC();
|
|
84
|
+
return fetchOrAbsent({
|
|
85
|
+
sourceID,
|
|
86
|
+
sourceFetchHLC,
|
|
87
|
+
read: async ()=>{
|
|
88
|
+
const record = await getPerson({
|
|
89
|
+
accessToken: config.credential.accessToken,
|
|
90
|
+
sourceID,
|
|
91
|
+
fetch: fetchFn
|
|
92
|
+
});
|
|
93
|
+
return {
|
|
94
|
+
record
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
// Provider-authoritative re-read for write-back recovery. Unlike
|
|
100
|
+
// fetchEntity, the present:true branch also surfaces a providerETag when the
|
|
101
|
+
// provider reports one — the current revision an update's conditional write
|
|
102
|
+
// can condition on.
|
|
103
|
+
async function reconcile(_model, sourceID) {
|
|
104
|
+
const sourceFetchHLC = mintFetchHLC();
|
|
105
|
+
return fetchOrAbsent({
|
|
106
|
+
sourceID,
|
|
107
|
+
sourceFetchHLC,
|
|
108
|
+
read: async ()=>{
|
|
109
|
+
const { record, etag } = await getPersonWithETag({
|
|
110
|
+
accessToken: config.credential.accessToken,
|
|
111
|
+
sourceID,
|
|
112
|
+
fetch: fetchFn
|
|
113
|
+
});
|
|
114
|
+
return {
|
|
115
|
+
record,
|
|
116
|
+
providerETag: etag
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
42
121
|
return {
|
|
43
122
|
supportedStrategies: [
|
|
44
123
|
'on-demand'
|
|
45
124
|
],
|
|
46
125
|
fetchAll: fetchAllEntities,
|
|
126
|
+
fetchEntity,
|
|
127
|
+
reconcile,
|
|
47
128
|
async *fetchChanges (params) {
|
|
48
129
|
const syncToken = params.checkpoint;
|
|
49
130
|
if (syncToken == null) {
|
|
50
131
|
yield* fetchAllEntities(params);
|
|
51
132
|
return;
|
|
52
133
|
}
|
|
53
|
-
|
|
54
|
-
do {
|
|
55
|
-
const data = await fetchPage({
|
|
56
|
-
pageToken,
|
|
57
|
-
syncToken: pageToken != null ? undefined : syncToken,
|
|
58
|
-
signal: params.signal
|
|
59
|
-
});
|
|
60
|
-
const entities = (data.connections ?? []).map(mapPersonToEntity);
|
|
61
|
-
pageToken = data.nextPageToken;
|
|
62
|
-
yield {
|
|
63
|
-
modelID: GOOGLE_CONTACT_MODEL_ID,
|
|
64
|
-
entities,
|
|
65
|
-
hasMore: pageToken != null,
|
|
66
|
-
checkpoint: data.nextSyncToken
|
|
67
|
-
};
|
|
68
|
-
}while (pageToken != null)
|
|
134
|
+
yield* fetchIncrementalEntities(params);
|
|
69
135
|
}
|
|
70
136
|
};
|
|
71
137
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/connector-google-contacts",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.2",
|
|
4
4
|
"keywords": [],
|
|
5
5
|
"license": "see LICENSE.md",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -15,11 +15,12 @@
|
|
|
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.3",
|
|
19
|
+
"@kubun/credential-types": "^0.17.2",
|
|
20
|
+
"@kubun/hlc": "^0.17.0",
|
|
20
21
|
"@kubun/models": "^0.17.0",
|
|
21
|
-
"@kubun/protocol": "^0.17.
|
|
22
|
-
"@kubun/provider-google": "^0.17.
|
|
22
|
+
"@kubun/protocol": "^0.17.2",
|
|
23
|
+
"@kubun/provider-google": "^0.17.1",
|
|
23
24
|
"@kubun/scalars": "^0.17.0"
|
|
24
25
|
},
|
|
25
26
|
"publishConfig": {
|