@kubun/connector-google-contacts 0.17.0 → 0.17.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.d.ts CHANGED
@@ -5,5 +5,6 @@ export type GoogleContactsActionHandlerConfig = {
5
5
  scopes: Array<string>;
6
6
  };
7
7
  fetch?: typeof globalThis.fetch;
8
+ mintFetchHLC: () => string;
8
9
  };
9
10
  export declare function createGoogleContactsActionHandler(config: GoogleContactsActionHandlerConfig): ActionHandler;
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
- return createContact({
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
- return updateContact({
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,4 @@
1
- import type { EntityRecord } from '@kubun/connector';
1
+ import { type EntityRecord } from '@kubun/connector';
2
2
  export type GooglePersonResource = {
3
3
  resourceName: string;
4
4
  etag?: string;
@@ -20,6 +20,9 @@ export type GooglePersonResource = {
20
20
  photos?: Array<{
21
21
  url?: string;
22
22
  }>;
23
+ metadata?: {
24
+ deleted?: boolean;
25
+ };
23
26
  };
24
27
  export type GoogleConnectionsResponse = {
25
28
  connections?: Array<GooglePersonResource>;
@@ -34,6 +37,19 @@ export declare function buildListURL(params: {
34
37
  }): string;
35
38
  export declare function mapPersonToEntity(person: GooglePersonResource): EntityRecord;
36
39
  export declare function buildPersonFromParams(params: Record<string, unknown>): Partial<GooglePersonResource>;
40
+ export declare function getPerson(config: {
41
+ accessToken: string;
42
+ sourceID: string;
43
+ fetch?: typeof globalThis.fetch;
44
+ }): Promise<EntityRecord>;
45
+ export declare function getPersonWithETag(config: {
46
+ accessToken: string;
47
+ sourceID: string;
48
+ fetch?: typeof globalThis.fetch;
49
+ }): Promise<{
50
+ record: EntityRecord;
51
+ etag?: string;
52
+ }>;
37
53
  export type CreateContactParams = {
38
54
  accessToken: string;
39
55
  params: Record<string, unknown>;
@@ -44,6 +60,13 @@ export type UpdateContactParams = {
44
60
  accessToken: string;
45
61
  sourceID: string;
46
62
  params: Record<string, unknown>;
63
+ /**
64
+ * The contact's etag as last fetched. The People API writes a contact only
65
+ * against its etag and refuses (FAILED_PRECONDITION) a stale one, so a
66
+ * contact changed at Google since is never overwritten. It is never re-read
67
+ * right before the write — that would always match and guard nothing.
68
+ */
69
+ etag?: string | null;
47
70
  fetch?: typeof globalThis.fetch;
48
71
  };
49
72
  export declare function updateContact(options: UpdateContactParams): Promise<EntityRecord>;
package/lib/api.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { classifyHTTPStatus, connectorHTTPError } from '@kubun/connector';
2
+ import { ContactEtagRequired, GoogleContactsError } from './errors.js';
1
3
  const PERSON_FIELDS = 'names,emailAddresses,phoneNumbers,organizations,photos';
2
4
  const CONTACTS_BASE_URL = 'https://people.googleapis.com/v1';
3
5
  // A Google People API resource name is `people/{id}`. A sourceID reaches here
@@ -10,14 +12,43 @@ function assertValidSourceID(sourceID) {
10
12
  throw new Error(`Invalid contact sourceID: ${JSON.stringify(sourceID)}`);
11
13
  }
12
14
  }
13
- async function parseGoogleError(response) {
15
+ async function parseGoogleErrorBody(response) {
14
16
  try {
15
17
  const body = await response.json();
16
- return body.error?.message ?? `${response.status} ${response.statusText}`;
18
+ return {
19
+ message: body.error?.message ?? `${response.status} ${response.statusText}`,
20
+ status: body.error?.status
21
+ };
17
22
  } catch {
18
- return `${response.status} ${response.statusText}`;
23
+ return {
24
+ message: `${response.status} ${response.statusText}`
25
+ };
19
26
  }
20
27
  }
28
+ async function parseGoogleError(response) {
29
+ return (await parseGoogleErrorBody(response)).message;
30
+ }
31
+ // Build the error thrown by a People API call, tagged with the HTTP status and
32
+ // its outcome classification (`kind`, see `classifyHTTPStatus`) — a definite
33
+ // rejection dead-letters a write instead of re-driving it. The status is also
34
+ // the seam fetchEntity/reconcile (provider.ts) use to turn a 404 into a
35
+ // `present: false` result (deletion detected) instead of a throw: a Google
36
+ // person resource has no `metadata.deleted` flag on a direct get — the 404
37
+ // itself IS the deletion signal.
38
+ function googlePeopleAPIError(status, detail) {
39
+ return connectorHTTPError(`Google People API error: ${detail}`, status);
40
+ }
41
+ // A write answered with FAILED_PRECONDITION (a 400) was refused because the
42
+ // contact changed at Google since the etag it was conditioned on — the People
43
+ // API's form of a 412. Like a 412 it is outcome-unknown, never a rejection:
44
+ // recovery reconciles against the contact's current state.
45
+ async function googlePeopleWriteError(response) {
46
+ const { message, status } = await parseGoogleErrorBody(response);
47
+ return new GoogleContactsError(`Google People API error: ${message}`, {
48
+ httpStatus: response.status,
49
+ kind: status === 'FAILED_PRECONDITION' ? 'unknown' : classifyHTTPStatus(response.status)
50
+ });
51
+ }
21
52
  export function buildListURL(params) {
22
53
  const url = new URL(`${CONTACTS_BASE_URL}/people/me/connections`);
23
54
  url.searchParams.set('personFields', PERSON_FIELDS);
@@ -43,7 +74,8 @@ export function mapPersonToEntity(person) {
43
74
  organization: person.organizations?.[0]?.name ?? undefined,
44
75
  jobTitle: person.organizations?.[0]?.title ?? undefined,
45
76
  photoURL: person.photos?.[0]?.url ?? undefined,
46
- sourceURL: `https://contacts.google.com/person/${person.resourceName.replace('people/', '')}`
77
+ sourceURL: `https://contacts.google.com/person/${person.resourceName.replace('people/', '')}`,
78
+ etag: person.etag
47
79
  };
48
80
  }
49
81
  export function buildPersonFromParams(params) {
@@ -83,6 +115,41 @@ export function buildPersonFromParams(params) {
83
115
  }
84
116
  return person;
85
117
  }
118
+ // Raw people.get, shared by getPerson/getPersonWithETag (fetchEntity/
119
+ // reconcile's targeted read) — a single choke point for sourceID validation
120
+ // and 404 tagging.
121
+ async function fetchPersonResource(config) {
122
+ assertValidSourceID(config.sourceID);
123
+ const fetchFn = config.fetch ?? globalThis.fetch;
124
+ const url = `${CONTACTS_BASE_URL}/${config.sourceID}?personFields=${PERSON_FIELDS}`;
125
+ const response = await fetchFn(url, {
126
+ headers: {
127
+ Authorization: `Bearer ${config.accessToken}`
128
+ }
129
+ });
130
+ if (!response.ok) {
131
+ const errorMessage = await parseGoogleError(response);
132
+ throw googlePeopleAPIError(response.status, errorMessage);
133
+ }
134
+ return await response.json();
135
+ }
136
+ // Targeted single-contact fetch (anti-entropy repair verification / write-back
137
+ // recovery's fetchEntity). A 404 here is the deletion signal — see
138
+ // googlePeopleAPIError's doc.
139
+ export async function getPerson(config) {
140
+ const data = await fetchPersonResource(config);
141
+ return mapPersonToEntity(data);
142
+ }
143
+ // Same as getPerson, but also surfaces the provider's etag — used by
144
+ // write-back recovery's reconcile read, which reports a providerETag
145
+ // alongside the record.
146
+ export async function getPersonWithETag(config) {
147
+ const data = await fetchPersonResource(config);
148
+ return {
149
+ record: mapPersonToEntity(data),
150
+ etag: data.etag
151
+ };
152
+ }
86
153
  export async function createContact(options) {
87
154
  const fetchFn = options.fetch ?? globalThis.fetch;
88
155
  const person = buildPersonFromParams(options.params);
@@ -96,28 +163,23 @@ export async function createContact(options) {
96
163
  });
97
164
  if (!response.ok) {
98
165
  const errorMessage = await parseGoogleError(response);
99
- throw new Error(`Google People API error: ${errorMessage}`);
166
+ throw googlePeopleAPIError(response.status, errorMessage);
100
167
  }
101
168
  const created = await response.json();
102
169
  return mapPersonToEntity(created);
103
170
  }
171
+ function contactEtagRequired() {
172
+ return new ContactEtagRequired();
173
+ }
104
174
  export async function updateContact(options) {
105
175
  assertValidSourceID(options.sourceID);
176
+ // The People API requires an etag on every update. Without the stored one
177
+ // the only way to get one is a fresh read, which would overwrite a change
178
+ // made at Google since the edit: refuse before any request instead.
179
+ if (options.etag == null || options.etag === '') throw contactEtagRequired();
106
180
  const fetchFn = options.fetch ?? globalThis.fetch;
107
- // Fetch current person to get etag (required by Google People API for updates)
108
- const getURL = `${CONTACTS_BASE_URL}/${options.sourceID}?personFields=${PERSON_FIELDS}`;
109
- const getResponse = await fetchFn(getURL, {
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();
119
181
  const person = buildPersonFromParams(options.params);
120
- person.etag = current.etag;
182
+ person.etag = options.etag;
121
183
  const updatePersonFields = [];
122
184
  if (person.names != null) updatePersonFields.push('names');
123
185
  if (person.emailAddresses != null) updatePersonFields.push('emailAddresses');
@@ -132,10 +194,7 @@ export async function updateContact(options) {
132
194
  },
133
195
  body: JSON.stringify(person)
134
196
  });
135
- if (!response.ok) {
136
- const errorMessage = await parseGoogleError(response);
137
- throw new Error(`Google People API error: ${errorMessage}`);
138
- }
197
+ if (!response.ok) throw await googlePeopleWriteError(response);
139
198
  const updated = await response.json();
140
199
  return mapPersonToEntity(updated);
141
200
  }
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
  };
@@ -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';
@@ -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,6 +35,9 @@ 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;
@@ -65,5 +68,8 @@ export declare const googleContactDefinition: {
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
@@ -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: [
@@ -67,7 +70,25 @@ export const googleContactDefinition = {
67
70
  DateTime: dateTimeSchema
68
71
  }
69
72
  },
70
- fieldsMeta: {}
73
+ fieldsMeta: {},
74
+ // lastSyncedAt is a device-local sync-bookkeeping annotation, not fetched
75
+ // provider content, so it stays outside the group.
76
+ providerMergePolicy: {
77
+ group: [
78
+ 'displayName',
79
+ 'sourceService',
80
+ 'sourceID',
81
+ 'sourceURL',
82
+ 'email',
83
+ 'phoneNumber',
84
+ 'organization',
85
+ 'jobTitle',
86
+ 'photoURL',
87
+ // The provider revision of this state: an update is written against it,
88
+ // so it must travel with the fields it versions.
89
+ 'etag'
90
+ ]
91
+ }
71
92
  };
72
93
  const builder = new GraphBuilder({
73
94
  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,9 +1,17 @@
1
1
  import { resolveModelID } from '@kubun/connector';
2
- import { buildListURL, mapPersonToEntity } from './api.js';
2
+ import { buildListURL, getPerson, getPersonWithETag, mapPersonToEntity } 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 mintFetchHLC = config.mintFetchHLC;
8
+ // getPerson/getPersonWithETag (api.ts) throw a plain Error tagged with
9
+ // `.httpStatus` (googlePeopleAPIError) — this is the seam fetchEntity/
10
+ // reconcile use to turn a 404 into a `present: false` result instead of a
11
+ // throw.
12
+ function isNotFound(err) {
13
+ return err?.httpStatus === 404;
14
+ }
7
15
  async function fetchPage(params) {
8
16
  const url = buildListURL({
9
17
  pageToken: params.pageToken,
@@ -28,7 +36,15 @@ export function createGoogleContactsProvider(config) {
28
36
  pageToken,
29
37
  signal: params.signal
30
38
  });
31
- const entities = (data.connections ?? []).map(mapPersonToEntity);
39
+ const sourceFetchHLC = mintFetchHLC();
40
+ // A first sync never carries a syncToken, so Google never returns a
41
+ // deleted tombstone on this path — deletion detection is
42
+ // incremental-only (see fetchIncrementalEntities).
43
+ const entities = (data.connections ?? []).map((person)=>({
44
+ present: true,
45
+ record: mapPersonToEntity(person),
46
+ sourceFetchHLC
47
+ }));
32
48
  pageToken = data.nextPageToken;
33
49
  syncToken = data.nextSyncToken;
34
50
  yield {
@@ -39,33 +55,112 @@ export function createGoogleContactsProvider(config) {
39
55
  };
40
56
  }while (pageToken != null)
41
57
  }
58
+ async function* fetchIncrementalEntities(params) {
59
+ const syncToken = params.checkpoint;
60
+ let pageToken;
61
+ do {
62
+ const data = await fetchPage({
63
+ pageToken,
64
+ syncToken: pageToken != null ? undefined : syncToken,
65
+ signal: params.signal
66
+ });
67
+ const sourceFetchHLC = mintFetchHLC();
68
+ const connections = data.connections ?? [];
69
+ // A removed contact comes back with only resourceName + metadata.deleted
70
+ // populated (all content fields absent) — split it into the deletion
71
+ // batch instead of mapping it as content.
72
+ const activePersons = connections.filter((p)=>p.metadata?.deleted !== true);
73
+ const deletedPersons = connections.filter((p)=>p.metadata?.deleted === true);
74
+ pageToken = data.nextPageToken;
75
+ yield {
76
+ modelID: GOOGLE_CONTACT_MODEL_ID,
77
+ entities: activePersons.map((person)=>({
78
+ present: true,
79
+ record: mapPersonToEntity(person),
80
+ sourceFetchHLC
81
+ })),
82
+ deleted: deletedPersons.map((person)=>({
83
+ present: false,
84
+ sourceID: person.resourceName,
85
+ sourceFetchHLC
86
+ })),
87
+ hasMore: pageToken != null,
88
+ checkpoint: data.nextSyncToken
89
+ };
90
+ }while (pageToken != null)
91
+ }
92
+ // Targeted single-entity fetch (anti-entropy repair verification). Mints its
93
+ // own fetch-order token at request start, independent of any batch token
94
+ // (see ServerProviderFactory.mintFetchHLC's doc).
95
+ async function fetchEntity(_model, sourceID) {
96
+ const sourceFetchHLC = mintFetchHLC();
97
+ try {
98
+ const record = await getPerson({
99
+ accessToken: config.credential.accessToken,
100
+ sourceID,
101
+ fetch: fetchFn
102
+ });
103
+ return {
104
+ present: true,
105
+ record,
106
+ sourceFetchHLC
107
+ };
108
+ } catch (err) {
109
+ if (isNotFound(err)) {
110
+ return {
111
+ present: false,
112
+ sourceID,
113
+ sourceFetchHLC
114
+ };
115
+ }
116
+ throw err;
117
+ }
118
+ }
119
+ // Provider-authoritative re-read for write-back recovery. Unlike
120
+ // fetchEntity, the present:true branch also surfaces a providerETag when the
121
+ // provider reports one — the current revision an update's conditional write
122
+ // can condition on.
123
+ async function reconcile(_model, sourceID) {
124
+ const sourceFetchHLC = mintFetchHLC();
125
+ try {
126
+ const { record, etag } = await getPersonWithETag({
127
+ accessToken: config.credential.accessToken,
128
+ sourceID,
129
+ fetch: fetchFn
130
+ });
131
+ return {
132
+ present: true,
133
+ record,
134
+ sourceFetchHLC,
135
+ ...etag != null ? {
136
+ providerETag: etag
137
+ } : {}
138
+ };
139
+ } catch (err) {
140
+ if (isNotFound(err)) {
141
+ return {
142
+ present: false,
143
+ sourceID,
144
+ sourceFetchHLC
145
+ };
146
+ }
147
+ throw err;
148
+ }
149
+ }
42
150
  return {
43
151
  supportedStrategies: [
44
152
  'on-demand'
45
153
  ],
46
154
  fetchAll: fetchAllEntities,
155
+ fetchEntity,
156
+ reconcile,
47
157
  async *fetchChanges (params) {
48
158
  const syncToken = params.checkpoint;
49
159
  if (syncToken == null) {
50
160
  yield* fetchAllEntities(params);
51
161
  return;
52
162
  }
53
- let pageToken;
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)
163
+ yield* fetchIncrementalEntities(params);
69
164
  }
70
165
  };
71
166
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/connector-google-contacts",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "keywords": [],
5
5
  "license": "see LICENSE.md",
6
6
  "sideEffects": false,
@@ -15,10 +15,11 @@
15
15
  "LICENSE.md"
16
16
  ],
17
17
  "dependencies": {
18
- "@kubun/connector": "^0.17.0",
19
- "@kubun/credential-types": "^0.17.0",
18
+ "@kubun/connector": "^0.17.2",
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.0",
22
+ "@kubun/protocol": "^0.17.1",
22
23
  "@kubun/provider-google": "^0.17.0",
23
24
  "@kubun/scalars": "^0.17.0"
24
25
  },