@memberjunction/integration-connectors 5.38.0 → 5.39.0
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/dist/AptifyConnector.d.ts +7 -0
- package/dist/AptifyConnector.d.ts.map +1 -1
- package/dist/AptifyConnector.js +34 -11
- package/dist/AptifyConnector.js.map +1 -1
- package/dist/BlackbaudConnector.js +1 -1
- package/dist/BlackbaudConnector.js.map +1 -1
- package/dist/ConstantContactConnector.d.ts +12 -1
- package/dist/ConstantContactConnector.d.ts.map +1 -1
- package/dist/ConstantContactConnector.js +32 -5
- package/dist/ConstantContactConnector.js.map +1 -1
- package/dist/HubSpotConnector.d.ts +162 -2
- package/dist/HubSpotConnector.d.ts.map +1 -1
- package/dist/HubSpotConnector.js +466 -44
- package/dist/HubSpotConnector.js.map +1 -1
- package/dist/IMISConnector.d.ts.map +1 -1
- package/dist/IMISConnector.js +1 -5
- package/dist/IMISConnector.js.map +1 -1
- package/dist/MagnetMailConnector.d.ts.map +1 -1
- package/dist/MagnetMailConnector.js +8 -1
- package/dist/MagnetMailConnector.js.map +1 -1
- package/dist/MailchimpConnector.d.ts +10 -1
- package/dist/MailchimpConnector.d.ts.map +1 -1
- package/dist/MailchimpConnector.js +39 -1
- package/dist/MailchimpConnector.js.map +1 -1
- package/dist/NetForumConnector.d.ts +27 -1
- package/dist/NetForumConnector.d.ts.map +1 -1
- package/dist/NetForumConnector.js +88 -2
- package/dist/NetForumConnector.js.map +1 -1
- package/dist/NimbleAMSConnector.d.ts.map +1 -1
- package/dist/NimbleAMSConnector.js +2 -1
- package/dist/NimbleAMSConnector.js.map +1 -1
- package/dist/PropFuelConnector.d.ts +11 -0
- package/dist/PropFuelConnector.d.ts.map +1 -1
- package/dist/PropFuelConnector.js +47 -10
- package/dist/PropFuelConnector.js.map +1 -1
- package/dist/QuickBooksConnector.d.ts.map +1 -1
- package/dist/QuickBooksConnector.js +30 -10
- package/dist/QuickBooksConnector.js.map +1 -1
- package/dist/RasaConnector.d.ts +9 -1
- package/dist/RasaConnector.d.ts.map +1 -1
- package/dist/RasaConnector.js +27 -0
- package/dist/RasaConnector.js.map +1 -1
- package/dist/Reach360Connector.d.ts +11 -1
- package/dist/Reach360Connector.d.ts.map +1 -1
- package/dist/Reach360Connector.js +96 -10
- package/dist/Reach360Connector.js.map +1 -1
- package/dist/SageIntacctConnector.js +1 -1
- package/dist/SageIntacctConnector.js.map +1 -1
- package/dist/SalesforceConnector.d.ts +21 -0
- package/dist/SalesforceConnector.d.ts.map +1 -1
- package/dist/SalesforceConnector.js +64 -2
- package/dist/SalesforceConnector.js.map +1 -1
- package/dist/SharePointConnector.js +1 -1
- package/dist/SharePointConnector.js.map +1 -1
- package/dist/WicketConnector.d.ts +12 -1
- package/dist/WicketConnector.d.ts.map +1 -1
- package/dist/WicketConnector.js +73 -2
- package/dist/WicketConnector.js.map +1 -1
- package/dist/WildApricotConnector.d.ts +13 -0
- package/dist/WildApricotConnector.d.ts.map +1 -1
- package/dist/WildApricotConnector.js +47 -10
- package/dist/WildApricotConnector.js.map +1 -1
- package/dist/YourMembershipConnector.d.ts +26 -0
- package/dist/YourMembershipConnector.d.ts.map +1 -1
- package/dist/YourMembershipConnector.js +49 -6
- package/dist/YourMembershipConnector.js.map +1 -1
- package/package.json +6 -6
package/dist/HubSpotConnector.js
CHANGED
|
@@ -17,6 +17,12 @@ const MAX_RETRIES = 5;
|
|
|
17
17
|
const REQUEST_TIMEOUT_MS = 30000;
|
|
18
18
|
/** Minimum milliseconds between API requests (HubSpot: 100 req/10s for private apps) */
|
|
19
19
|
const MIN_REQUEST_INTERVAL_MS = 100;
|
|
20
|
+
/**
|
|
21
|
+
* HubSpot CRM search API hard cap: the opaque `after` offset cannot page beyond 10,000 results
|
|
22
|
+
* within a single query window. Incremental windows larger than this (and same-`hs_lastmodifieddate`
|
|
23
|
+
* clusters bigger than 10k from bulk imports) must re-anchor by keyset to be fetched completely.
|
|
24
|
+
*/
|
|
25
|
+
const HUBSPOT_SEARCH_WINDOW_CAP = 10_000;
|
|
20
26
|
/**
|
|
21
27
|
* Comprehensive HubSpot object metadata — single source of truth for both
|
|
22
28
|
* action generation and API property requests.
|
|
@@ -31,6 +37,9 @@ const HUBSPOT_OBJECTS = [
|
|
|
31
37
|
{
|
|
32
38
|
Name: 'contacts', DisplayName: 'Contact',
|
|
33
39
|
Description: 'A person or lead in HubSpot CRM', SupportsWrite: true,
|
|
40
|
+
// Contacts upsert by email — the natural unique key. Drives the idempotent Upsert verb,
|
|
41
|
+
// which defines the contact-create collision race (409 Contact already exists) out of existence.
|
|
42
|
+
UpsertKey: 'email',
|
|
34
43
|
Fields: [
|
|
35
44
|
{ Name: 'email', DisplayName: 'Email', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Contact email address' },
|
|
36
45
|
{ Name: 'firstname', DisplayName: 'First Name', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Contact first name' },
|
|
@@ -915,6 +924,8 @@ let HubSpotConnector = class HubSpotConnector extends BaseRESTIntegrationConnect
|
|
|
915
924
|
this._config = null;
|
|
916
925
|
/** Cached auth context — reused within a session to avoid redundant credential loads */
|
|
917
926
|
this._cachedAuth = null;
|
|
927
|
+
/** Cache of resolved default association typeIds, keyed by `${fromType}/${toType}`. */
|
|
928
|
+
this._assocTypeIdCache = new Map();
|
|
918
929
|
}
|
|
919
930
|
static { HubSpotConnector_1 = this; }
|
|
920
931
|
// ── Per-instance config accessors (fall back to module-level defaults) ──
|
|
@@ -924,10 +935,31 @@ let HubSpotConnector = class HubSpotConnector extends BaseRESTIntegrationConnect
|
|
|
924
935
|
// ─── Capability Getters ──────────────────────────────────────────────
|
|
925
936
|
get SupportsCreate() { return true; }
|
|
926
937
|
get SupportsUpdate() { return true; }
|
|
938
|
+
get SupportsUpsert() { return true; }
|
|
927
939
|
get SupportsDelete() { return true; }
|
|
928
940
|
get SupportsSearch() { return true; }
|
|
929
941
|
get SupportsListing() { return true; }
|
|
930
942
|
get IntegrationName() { return 'HubSpot'; }
|
|
943
|
+
// ─── §7 sync-efficiency contract (HubSpot = the full reference implementation) ──────────
|
|
944
|
+
// The engine consumes these for adaptive rate limiting, peak parallelization, and precise 429
|
|
945
|
+
// back-off. HubSpot's public limit is ~100-110 requests / 10s per Private App; the connector's
|
|
946
|
+
// MinRequestIntervalMs (default 100ms) is the sustained pace.
|
|
947
|
+
/** ~10 req/s sustained (honors MinRequestIntervalMs config) with a ~100-request burst window. */
|
|
948
|
+
get RateLimitPolicy() {
|
|
949
|
+
const interval = this.effectiveMinRequestIntervalMs;
|
|
950
|
+
return {
|
|
951
|
+
TokensPerSec: Math.max(1, Math.round(1000 / (interval > 0 ? interval : 100))),
|
|
952
|
+
Burst: 100,
|
|
953
|
+
ThrottleBackoffFactor: 0.5,
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
/** HubSpot rate-limits on a rolling 10-second window; on a 429 that escaped internal retries, back off ~10s. */
|
|
957
|
+
ExtractRetryAfterMs(error) {
|
|
958
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
959
|
+
return /\b429\b|rate.?limit|too many requests/i.test(msg) ? 10_000 : undefined;
|
|
960
|
+
}
|
|
961
|
+
/** HubSpot tolerates modest object-level parallelism; the engine's AIMD controller ramps toward this, with the token-bucket as the real backstop. */
|
|
962
|
+
get MaxConcurrencyHint() { return 4; }
|
|
931
963
|
// ─── Action Metadata ─────────────────────────────────────────────────
|
|
932
964
|
GetIntegrationObjects() {
|
|
933
965
|
return HUBSPOT_OBJECTS;
|
|
@@ -1087,7 +1119,8 @@ let HubSpotConnector = class HubSpotConnector extends BaseRESTIntegrationConnect
|
|
|
1087
1119
|
*/
|
|
1088
1120
|
static { this.ASSOCIATION_OBJECTS = [
|
|
1089
1121
|
{ name: 'assoc_contacts_companies', label: 'Contact ↔ Company', description: 'Associations between contacts and companies', apiPath: '/crm/v4/associations/contacts/companies', pkFields: ['contact_id', 'company_id'] },
|
|
1090
|
-
|
|
1122
|
+
// deal->contact = 3 (HUBSPOT_DEFINED, verified via /labels). Wire from=deal even though stored key is contact|deal.
|
|
1123
|
+
{ name: 'assoc_contacts_deals', label: 'Contact ↔ Deal', description: 'Associations between contacts and deals', apiPath: '/crm/v4/associations/contacts/deals', pkFields: ['contact_id', 'deal_id'], fromType: 'deals', toType: 'contacts', fromPkField: 'deal_id', toPkField: 'contact_id', associationCategory: 'HUBSPOT_DEFINED', associationTypeId: 3 },
|
|
1091
1124
|
{ name: 'assoc_contacts_tickets', label: 'Contact ↔ Ticket', description: 'Associations between contacts and tickets', apiPath: '/crm/v4/associations/contacts/tickets', pkFields: ['contact_id', 'ticket_id'] },
|
|
1092
1125
|
{ name: 'assoc_contacts_calls', label: 'Contact ↔ Call', description: 'Associations between contacts and calls', apiPath: '/crm/v4/associations/contacts/calls', pkFields: ['contact_id', 'call_id'] },
|
|
1093
1126
|
{ name: 'assoc_contacts_emails', label: 'Contact ↔ Email', description: 'Associations between contacts and emails', apiPath: '/crm/v4/associations/contacts/emails', pkFields: ['contact_id', 'email_id'] },
|
|
@@ -1095,7 +1128,8 @@ let HubSpotConnector = class HubSpotConnector extends BaseRESTIntegrationConnect
|
|
|
1095
1128
|
{ name: 'assoc_contacts_notes', label: 'Contact ↔ Note', description: 'Associations between contacts and notes', apiPath: '/crm/v4/associations/contacts/notes', pkFields: ['contact_id', 'note_id'] },
|
|
1096
1129
|
{ name: 'assoc_contacts_tasks', label: 'Contact ↔ Task', description: 'Associations between contacts and tasks', apiPath: '/crm/v4/associations/contacts/tasks', pkFields: ['contact_id', 'task_id'] },
|
|
1097
1130
|
{ name: 'assoc_contacts_feedback_submissions', label: 'Contact ↔ Feedback Submission', description: 'Associations between contacts and feedback submissions', apiPath: '/crm/v4/associations/contacts/feedback_submissions', pkFields: ['contact_id', 'feedback_submission_id'] },
|
|
1098
|
-
|
|
1131
|
+
// No hardcoded typeId — resolved at runtime via /labels (deals/companies exposes multiple HUBSPOT_DEFINED defaults).
|
|
1132
|
+
{ name: 'assoc_companies_deals', label: 'Company ↔ Deal', description: 'Associations between companies and deals', apiPath: '/crm/v4/associations/companies/deals', pkFields: ['company_id', 'deal_id'], fromType: 'companies', toType: 'deals', fromPkField: 'company_id', toPkField: 'deal_id', associationCategory: 'HUBSPOT_DEFINED' },
|
|
1099
1133
|
{ name: 'assoc_companies_tickets', label: 'Company ↔ Ticket', description: 'Associations between companies and tickets', apiPath: '/crm/v4/associations/companies/tickets', pkFields: ['company_id', 'ticket_id'] },
|
|
1100
1134
|
{ name: 'assoc_companies_calls', label: 'Company ↔ Call', description: 'Associations between companies and calls', apiPath: '/crm/v4/associations/companies/calls', pkFields: ['company_id', 'call_id'] },
|
|
1101
1135
|
{ name: 'assoc_companies_emails', label: 'Company ↔ Email', description: 'Associations between companies and emails', apiPath: '/crm/v4/associations/companies/emails', pkFields: ['company_id', 'email_id'] },
|
|
@@ -1233,11 +1267,16 @@ let HubSpotConnector = class HubSpotConnector extends BaseRESTIntegrationConnect
|
|
|
1233
1267
|
IsUniqueKey: false, // hasUniqueValue is HubSpot field-level uniqueness, not the record PK; only hs_object_id is the true unique key
|
|
1234
1268
|
IsReadOnly: p.modificationMetadata?.readOnlyValue === true || p.calculated,
|
|
1235
1269
|
}));
|
|
1236
|
-
// Ensure hs_object_id is the
|
|
1270
|
+
// Ensure hs_object_id is the record PK — HubSpot's Properties API sets
|
|
1237
1271
|
// hasUniqueValue=false for hs_object_id even though it IS the record identifier,
|
|
1238
|
-
//
|
|
1272
|
+
// AND the API never returns an IsPrimaryKey signal at all (PK lives in the
|
|
1273
|
+
// response envelope, not in property metadata). We must therefore stamp
|
|
1274
|
+
// IsPrimaryKey + IsUniqueKey + IsReadOnly on this field explicitly. Without
|
|
1275
|
+
// the IsPrimaryKey stamp, UpsertField's new-field path persists it without a
|
|
1276
|
+
// PK flag and the downstream SoftPKClassifier becomes our only safety net.
|
|
1239
1277
|
const pkField = fields.find(f => f.Name === 'hs_object_id');
|
|
1240
1278
|
if (pkField) {
|
|
1279
|
+
pkField.IsPrimaryKey = true;
|
|
1241
1280
|
pkField.IsUniqueKey = true;
|
|
1242
1281
|
pkField.IsReadOnly = true;
|
|
1243
1282
|
}
|
|
@@ -1248,6 +1287,7 @@ let HubSpotConnector = class HubSpotConnector extends BaseRESTIntegrationConnect
|
|
|
1248
1287
|
Description: 'HubSpot internal object ID',
|
|
1249
1288
|
DataType: 'string',
|
|
1250
1289
|
IsRequired: true,
|
|
1290
|
+
IsPrimaryKey: true,
|
|
1251
1291
|
IsUniqueKey: true,
|
|
1252
1292
|
IsReadOnly: true,
|
|
1253
1293
|
});
|
|
@@ -1291,6 +1331,36 @@ let HubSpotConnector = class HubSpotConnector extends BaseRESTIntegrationConnect
|
|
|
1291
1331
|
IsReadOnly: true,
|
|
1292
1332
|
}];
|
|
1293
1333
|
}
|
|
1334
|
+
/**
|
|
1335
|
+
* Priority-ordered list of HubSpot "last changed" timestamp field names, used to
|
|
1336
|
+
* populate SourceObjectInfo.IncrementalWatermarkField. Every name here is a field
|
|
1337
|
+
* the connector ALREADY declares on its objects — CRM objects expose
|
|
1338
|
+
* `hs_lastmodifieddate` (contacts use the legacy `lastmodifieddate`), while non-CRM
|
|
1339
|
+
* REST objects expose `updatedAt`. Provable-only: the watermark is set on an object
|
|
1340
|
+
* solely from that object's own declared field list, never invented.
|
|
1341
|
+
*/
|
|
1342
|
+
static { this.WATERMARK_FIELD_CANDIDATES = [
|
|
1343
|
+
'hs_lastmodifieddate',
|
|
1344
|
+
'lastmodifieddate',
|
|
1345
|
+
'updatedAt',
|
|
1346
|
+
]; }
|
|
1347
|
+
/**
|
|
1348
|
+
* Promotes an object's own declared "last changed" timestamp field into the
|
|
1349
|
+
* IncrementalWatermarkField slot. Returns the first candidate present in the
|
|
1350
|
+
* supplied field-name set, or undefined when the object declares none — an honest
|
|
1351
|
+
* gap rather than a fabricated watermark. Matching is case-insensitive so a
|
|
1352
|
+
* DB-cached field list (which may differ in casing) still resolves.
|
|
1353
|
+
*/
|
|
1354
|
+
PickIncrementalWatermarkField(fieldNames) {
|
|
1355
|
+
const present = new Set(fieldNames.map(n => n.toLowerCase()));
|
|
1356
|
+
for (const candidate of HubSpotConnector_1.WATERMARK_FIELD_CANDIDATES) {
|
|
1357
|
+
if (present.has(candidate.toLowerCase())) {
|
|
1358
|
+
// Return the actually-declared name (preserve its real casing).
|
|
1359
|
+
return fieldNames.find(n => n.toLowerCase() === candidate.toLowerCase());
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
return undefined;
|
|
1363
|
+
}
|
|
1294
1364
|
/**
|
|
1295
1365
|
* Full schema introspection — discovers all objects and their fields from the live API.
|
|
1296
1366
|
*/
|
|
@@ -1367,6 +1437,7 @@ let HubSpotConnector = class HubSpotConnector extends BaseRESTIntegrationConnect
|
|
|
1367
1437
|
Fields: sourceFields,
|
|
1368
1438
|
PrimaryKeyFields: pkFields.map(f => f.Name),
|
|
1369
1439
|
Relationships: [],
|
|
1440
|
+
IncrementalWatermarkField: this.PickIncrementalWatermarkField(sourceFields.map(f => f.Name)),
|
|
1370
1441
|
});
|
|
1371
1442
|
}
|
|
1372
1443
|
catch (err) {
|
|
@@ -1404,6 +1475,7 @@ let HubSpotConnector = class HubSpotConnector extends BaseRESTIntegrationConnect
|
|
|
1404
1475
|
Fields: sourceFields,
|
|
1405
1476
|
PrimaryKeyFields: pkFields.map(f => f.Name),
|
|
1406
1477
|
Relationships: [],
|
|
1478
|
+
IncrementalWatermarkField: this.PickIncrementalWatermarkField(sourceFields.map(f => f.Name)),
|
|
1407
1479
|
});
|
|
1408
1480
|
console.log(`[HubSpot] Used ${dbFields.length} DB-cached fields for "${obj.Name}" after exception`);
|
|
1409
1481
|
continue;
|
|
@@ -1489,6 +1561,64 @@ let HubSpotConnector = class HubSpotConnector extends BaseRESTIntegrationConnect
|
|
|
1489
1561
|
}
|
|
1490
1562
|
return this.BuildCRUDErrorResult(response, 'UpdateRecord', ctx.ObjectName);
|
|
1491
1563
|
}
|
|
1564
|
+
/**
|
|
1565
|
+
* Idempotently creates-or-updates a record keyed by a unique business property
|
|
1566
|
+
* (default: the object's `UpsertKey` metadata, e.g. 'email' for contacts).
|
|
1567
|
+
*
|
|
1568
|
+
* Uses HubSpot's batch/upsert endpoint with a batch of one. This is the ONLY HubSpot
|
|
1569
|
+
* single-call idempotent path verified against the live API: the single-record
|
|
1570
|
+
* PATCH .../{id}?idProperty=email does NOT create-on-missing (returns 404), while
|
|
1571
|
+
* POST .../batch/upsert creates-on-missing and updates-on-existing with a 2xx (no 409).
|
|
1572
|
+
* A batch of one sidesteps the documented batch caveats (whole-batch-409 on concurrent
|
|
1573
|
+
* batches, no partial upserts) that only bite multi-input batches.
|
|
1574
|
+
*
|
|
1575
|
+
* This *defines the error out of existence*: a search-then-create sequence has a window in
|
|
1576
|
+
* which a concurrent writer can create the same email-keyed contact, yielding
|
|
1577
|
+
* `409 Contact already exists`. Rather than catch and special-case that 409, the single keyed
|
|
1578
|
+
* upsert removes the window entirely — the collision is no longer a condition the caller (or
|
|
1579
|
+
* this code) ever has to handle.
|
|
1580
|
+
*/
|
|
1581
|
+
async Upsert(ctx) {
|
|
1582
|
+
const idProperty = ctx.IDProperty ?? this.GetUpsertKey(ctx.ObjectName);
|
|
1583
|
+
if (!idProperty) {
|
|
1584
|
+
return {
|
|
1585
|
+
Success: false,
|
|
1586
|
+
StatusCode: 400,
|
|
1587
|
+
ErrorMessage: `[HubSpot] Upsert on '${ctx.ObjectName}' has no upsert key — set ctx.IDProperty or declare UpsertKey in object metadata`,
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1590
|
+
const idValue = ctx.Attributes[idProperty];
|
|
1591
|
+
if (idValue == null || String(idValue).length === 0) {
|
|
1592
|
+
return {
|
|
1593
|
+
Success: false,
|
|
1594
|
+
StatusCode: 400,
|
|
1595
|
+
ErrorMessage: `[HubSpot] Upsert on '${ctx.ObjectName}' is missing a value for the upsert key '${idProperty}' in Attributes`,
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
const companyIntegration = ctx.CompanyIntegration;
|
|
1599
|
+
const contextUser = ctx.ContextUser;
|
|
1600
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
1601
|
+
const headers = this.BuildHeaders(auth);
|
|
1602
|
+
const url = `${HUBSPOT_API_BASE}/crm/v3/objects/${ctx.ObjectName}/batch/upsert`;
|
|
1603
|
+
// Batch of one: id is the upsert-key value; properties carry the full record.
|
|
1604
|
+
const body = { inputs: [{ idProperty, id: String(idValue), properties: ctx.Attributes }] };
|
|
1605
|
+
const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, body);
|
|
1606
|
+
// Never trust a bare 2xx — the batch envelope can report per-input errors with a 2xx.
|
|
1607
|
+
const batchError = this.GetBatchUpsertError(response);
|
|
1608
|
+
if (batchError) {
|
|
1609
|
+
return {
|
|
1610
|
+
Success: false,
|
|
1611
|
+
StatusCode: response.Status,
|
|
1612
|
+
ErrorMessage: `[HubSpot] Upsert on ${ctx.ObjectName}: ${batchError}`,
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
const upserted = response.Body.results?.[0];
|
|
1616
|
+
return {
|
|
1617
|
+
Success: true,
|
|
1618
|
+
ExternalID: String(upserted?.id ?? ''),
|
|
1619
|
+
StatusCode: response.Status,
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1492
1622
|
/**
|
|
1493
1623
|
* Deletes (archives) a record in HubSpot by ExternalID.
|
|
1494
1624
|
* Routes association objects to the v4 batch/archive endpoint instead of v3 objects.
|
|
@@ -1533,16 +1663,29 @@ let HubSpotConnector = class HubSpotConnector extends BaseRESTIntegrationConnect
|
|
|
1533
1663
|
ErrorMessage: `CreateAssociation ${objectName}: missing PK fields '${leftField}' or '${rightField}' in attributes`,
|
|
1534
1664
|
};
|
|
1535
1665
|
}
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
const toType =
|
|
1666
|
+
// Wire direction is explicit config, decoupled from pkFields/apiPath order.
|
|
1667
|
+
// attributes are keyed by pkField name, so map from/to via fromPkField/toPkField.
|
|
1668
|
+
const { fromType, toType } = this.GetAssociationWireTypes(assocConfig);
|
|
1669
|
+
const fromID = assocConfig.fromPkField ? String(attributes[assocConfig.fromPkField] ?? '') : leftID;
|
|
1670
|
+
const toID = assocConfig.toPkField ? String(attributes[assocConfig.toPkField] ?? '') : rightID;
|
|
1671
|
+
// typeId: hardcoded verified-only fast path; otherwise resolve from /labels.
|
|
1672
|
+
const typeId = assocConfig.associationTypeId
|
|
1673
|
+
?? await this.ResolveAssociationTypeId(auth, headers, fromType, toType);
|
|
1674
|
+
if (typeId == null) {
|
|
1675
|
+
return { Success: false, ExternalID: '', StatusCode: 400, ErrorMessage: `CreateAssociation ${objectName}: could not resolve a HUBSPOT_DEFINED association typeId for ${fromType}->${toType}` };
|
|
1676
|
+
}
|
|
1677
|
+
const types = [{ associationCategory: assocConfig.associationCategory ?? 'HUBSPOT_DEFINED', associationTypeId: typeId }];
|
|
1539
1678
|
const url = `${HUBSPOT_API_BASE}/crm/v4/associations/${fromType}/${toType}/batch/create`;
|
|
1540
|
-
const body = { inputs: [{ from: { id:
|
|
1679
|
+
const body = { inputs: [{ from: { id: fromID }, to: { id: toID }, types }] };
|
|
1541
1680
|
const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, body);
|
|
1542
|
-
|
|
1681
|
+
// Never trust a bare 2xx — HubSpot returns 2xx with numErrors/empty results on
|
|
1682
|
+
// validation failures and on the old empty-types no-op.
|
|
1683
|
+
const batchError = this.GetAssociationBatchError(response);
|
|
1684
|
+
if (!batchError) {
|
|
1685
|
+
// Stored ExternalID stays in pkFields order (left|right), NOT wire order.
|
|
1543
1686
|
return { Success: true, ExternalID: `${leftID}|${rightID}`, StatusCode: response.Status };
|
|
1544
1687
|
}
|
|
1545
|
-
return
|
|
1688
|
+
return { Success: false, ExternalID: '', StatusCode: response.Status, ErrorMessage: `CreateAssociation ${objectName}: ${batchError}` };
|
|
1546
1689
|
}
|
|
1547
1690
|
/**
|
|
1548
1691
|
* Removes an association in HubSpot using the v4 batch/archive endpoint.
|
|
@@ -1564,11 +1707,15 @@ let HubSpotConnector = class HubSpotConnector extends BaseRESTIntegrationConnect
|
|
|
1564
1707
|
}
|
|
1565
1708
|
const leftID = externalID.substring(0, pipeIndex);
|
|
1566
1709
|
const rightID = externalID.substring(pipeIndex + 1);
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
const toType =
|
|
1710
|
+
// Stored key is in pkFields order (left=pkFields[0], right=pkFields[1]). Map to the
|
|
1711
|
+
// explicit wire direction so archive matches the create direction.
|
|
1712
|
+
const { fromType, toType } = this.GetAssociationWireTypes(assocConfig);
|
|
1713
|
+
const [leftPkField] = assocConfig.pkFields;
|
|
1714
|
+
const idByPkField = { [leftPkField]: leftID, [assocConfig.pkFields[1]]: rightID };
|
|
1715
|
+
const fromID = assocConfig.fromPkField ? idByPkField[assocConfig.fromPkField] : leftID;
|
|
1716
|
+
const toID = assocConfig.toPkField ? idByPkField[assocConfig.toPkField] : rightID;
|
|
1570
1717
|
const url = `${HUBSPOT_API_BASE}/crm/v4/associations/${fromType}/${toType}/batch/archive`;
|
|
1571
|
-
const body = { inputs: [{ from: { id:
|
|
1718
|
+
const body = { inputs: [{ from: { id: fromID }, to: { id: toID } }] };
|
|
1572
1719
|
const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, body);
|
|
1573
1720
|
// batch/archive returns 204 No Content on success
|
|
1574
1721
|
if (response.Status === 204 || (response.Status >= 200 && response.Status < 300)) {
|
|
@@ -1655,6 +1802,99 @@ let HubSpotConnector = class HubSpotConnector extends BaseRESTIntegrationConnect
|
|
|
1655
1802
|
throw new Error(`[HubSpot] ${operation} on ${objectName} failed (HTTP ${response.Status}): ${bodyPreview}`);
|
|
1656
1803
|
}
|
|
1657
1804
|
}
|
|
1805
|
+
/**
|
|
1806
|
+
* Resolves the v4 wire from/to object types for an association. Uses explicit fromType/toType
|
|
1807
|
+
* config when present; otherwise falls back to apiPath segment order. Both
|
|
1808
|
+
* CreateAssociation and DeleteAssociation share this so create and archive always agree.
|
|
1809
|
+
*/
|
|
1810
|
+
GetAssociationWireTypes(assocConfig) {
|
|
1811
|
+
const segments = assocConfig.apiPath.split('/').filter(Boolean);
|
|
1812
|
+
return {
|
|
1813
|
+
fromType: assocConfig.fromType ?? segments.at(-2),
|
|
1814
|
+
toType: assocConfig.toType ?? segments.at(-1),
|
|
1815
|
+
};
|
|
1816
|
+
}
|
|
1817
|
+
/**
|
|
1818
|
+
* Resolves the default HUBSPOT_DEFINED association typeId for a (fromType, toType) pair via
|
|
1819
|
+
* GET /crm/v4/associations/{fromType}/{toType}/labels, cached per pair for the connector's life.
|
|
1820
|
+
* Picks the unlabeled HUBSPOT_DEFINED entry (label === null) as the plain default; if none is
|
|
1821
|
+
* unlabeled, falls back to the sole/first HUBSPOT_DEFINED entry. Returns null on lookup failure
|
|
1822
|
+
* or when no HUBSPOT_DEFINED entry exists — callers MUST treat null as a hard error (never
|
|
1823
|
+
* silently send empty types).
|
|
1824
|
+
*/
|
|
1825
|
+
async ResolveAssociationTypeId(auth, headers, fromType, toType) {
|
|
1826
|
+
const cacheKey = `${fromType}/${toType}`;
|
|
1827
|
+
const cached = this._assocTypeIdCache.get(cacheKey);
|
|
1828
|
+
if (cached != null)
|
|
1829
|
+
return cached;
|
|
1830
|
+
const url = `${HUBSPOT_API_BASE}/crm/v4/associations/${fromType}/${toType}/labels`;
|
|
1831
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
1832
|
+
if (response.Status < 200 || response.Status >= 300) {
|
|
1833
|
+
console.warn(`[HubSpot] /labels lookup failed for ${cacheKey}: HTTP ${response.Status}`);
|
|
1834
|
+
return null;
|
|
1835
|
+
}
|
|
1836
|
+
const body = response.Body;
|
|
1837
|
+
const defined = (body?.results ?? []).filter(r => r.category === 'HUBSPOT_DEFINED' && r.typeId != null);
|
|
1838
|
+
if (defined.length === 0)
|
|
1839
|
+
return null;
|
|
1840
|
+
const unlabeled = defined.find(r => r.label == null);
|
|
1841
|
+
const chosen = (unlabeled ?? defined[0]).typeId;
|
|
1842
|
+
this._assocTypeIdCache.set(cacheKey, chosen);
|
|
1843
|
+
return chosen;
|
|
1844
|
+
}
|
|
1845
|
+
/**
|
|
1846
|
+
* Validates a v4 association batch/create response BODY (not just the HTTP status).
|
|
1847
|
+
* HubSpot returns 2xx even when zero associations are created — on the legacy empty-`types`
|
|
1848
|
+
* no-op (empty results, no errors) and on validation failures (empty results + numErrors).
|
|
1849
|
+
* Returns null when the operation genuinely completed; otherwise a human-readable error.
|
|
1850
|
+
* Predicate verified against live HubSpot batch/create responses.
|
|
1851
|
+
*/
|
|
1852
|
+
GetAssociationBatchError(response) {
|
|
1853
|
+
if (response.Status < 200 || response.Status >= 300) {
|
|
1854
|
+
const b = response.Body;
|
|
1855
|
+
return b?.['message'] ? String(b['message']) : `HTTP ${response.Status}`;
|
|
1856
|
+
}
|
|
1857
|
+
const body = response.Body;
|
|
1858
|
+
if (body?.numErrors || (body?.errors && body.errors.length > 0)) {
|
|
1859
|
+
return body.errors?.[0]?.message ?? `HubSpot reported ${body?.numErrors ?? body?.errors?.length} association error(s)`;
|
|
1860
|
+
}
|
|
1861
|
+
if (body?.status && body.status !== 'COMPLETE') {
|
|
1862
|
+
return `association batch status was '${body.status}', expected 'COMPLETE'`;
|
|
1863
|
+
}
|
|
1864
|
+
if (!body?.results || body.results.length === 0) {
|
|
1865
|
+
return `HubSpot returned no association results (2xx but nothing linked)`;
|
|
1866
|
+
}
|
|
1867
|
+
return null;
|
|
1868
|
+
}
|
|
1869
|
+
/**
|
|
1870
|
+
* Validates a v3 batch/upsert response BODY (not just the HTTP status). HubSpot's batch
|
|
1871
|
+
* envelope can return a 2xx while reporting per-input failures via `numErrors`/`errors`,
|
|
1872
|
+
* an incomplete `status`, or an empty `results` array. Returns null when the upsert
|
|
1873
|
+
* genuinely produced a record; otherwise a human-readable error. Mirrors the
|
|
1874
|
+
* GetAssociationBatchError precedent — never trust a bare 2xx on a batch endpoint.
|
|
1875
|
+
*/
|
|
1876
|
+
GetBatchUpsertError(response) {
|
|
1877
|
+
if (response.Status < 200 || response.Status >= 300) {
|
|
1878
|
+
const b = response.Body;
|
|
1879
|
+
return b?.['message'] ? String(b['message']) : `HTTP ${response.Status}`;
|
|
1880
|
+
}
|
|
1881
|
+
const body = response.Body;
|
|
1882
|
+
if (body?.numErrors || (body?.errors && body.errors.length > 0)) {
|
|
1883
|
+
return body.errors?.[0]?.message ?? `HubSpot reported ${body?.numErrors ?? body?.errors?.length} upsert error(s)`;
|
|
1884
|
+
}
|
|
1885
|
+
if (body?.status && body.status !== 'COMPLETE') {
|
|
1886
|
+
return `upsert batch status was '${body.status}', expected 'COMPLETE'`;
|
|
1887
|
+
}
|
|
1888
|
+
if (!body?.results || body.results.length === 0) {
|
|
1889
|
+
return `HubSpot returned no upsert results (2xx but nothing written)`;
|
|
1890
|
+
}
|
|
1891
|
+
// A 2xx result with no usable id means the write didn't really land — reporting success here
|
|
1892
|
+
// would hand the caller an empty ExternalID and silently break any later lookup keyed on it.
|
|
1893
|
+
if (body.results[0]?.id == null || String(body.results[0].id).length === 0) {
|
|
1894
|
+
return `HubSpot upsert result is missing an object id (2xx but no id to link)`;
|
|
1895
|
+
}
|
|
1896
|
+
return null;
|
|
1897
|
+
}
|
|
1658
1898
|
/** Builds a CRUDResult for error responses. */
|
|
1659
1899
|
BuildCRUDErrorResult(response, operation, objectName) {
|
|
1660
1900
|
const bodyObj = response.Body;
|
|
@@ -1729,7 +1969,10 @@ let HubSpotConnector = class HubSpotConnector extends BaseRESTIntegrationConnect
|
|
|
1729
1969
|
}
|
|
1730
1970
|
return this.BuildRESTResponse(response, responseBody);
|
|
1731
1971
|
}
|
|
1732
|
-
|
|
1972
|
+
// The loop only retries on 429 (every other status returns above), so exhausting retries here
|
|
1973
|
+
// means sustained 429 rate-limiting. Carry the "429 rate limit" marker so ClassifyError →
|
|
1974
|
+
// RATE_LIMIT_EXCEEDED and the engine's adaptive limiter / ExtractRetryAfterMs react correctly.
|
|
1975
|
+
throw new Error(`HubSpot 429 rate limit: request failed after ${maxRetries} retries (sustained throttling): ${url}`);
|
|
1733
1976
|
}
|
|
1734
1977
|
NormalizeResponse(rawBody, responseDataKey) {
|
|
1735
1978
|
const body = rawBody;
|
|
@@ -2319,6 +2562,26 @@ let HubSpotConnector = class HubSpotConnector extends BaseRESTIntegrationConnect
|
|
|
2319
2562
|
/**
|
|
2320
2563
|
* Fetches changed records using the HubSpot search API with server-side date filtering.
|
|
2321
2564
|
* Much more efficient than fetching ALL records and filtering client-side.
|
|
2565
|
+
*
|
|
2566
|
+
* Handles the search API's 10,000-results-per-window hard cap by keyset re-anchoring: results
|
|
2567
|
+
* are sorted by (dateField, hs_object_id) ASCENDING, paginated within a window by the API's
|
|
2568
|
+
* opaque `after` offset, and once that offset hits the 10k cap the NEXT window re-anchors with a
|
|
2569
|
+
* compound filter `(dateField > anchor) OR (dateField == anchor AND hs_object_id > anchorId)`.
|
|
2570
|
+
* This makes an incremental window — or a bulk-import cluster of >10k records that all share one
|
|
2571
|
+
* `hs_lastmodifieddate` — page through completely in a single sync, instead of the watermark
|
|
2572
|
+
* stalling on a same-timestamp cluster it can never advance past (which silently lost records).
|
|
2573
|
+
* The date GTE watermark remains the primary filter throughout, so incremental sync is preserved.
|
|
2574
|
+
*
|
|
2575
|
+
* LIVE-VERIFY (confirm during the credentialed run against a real >10k same-timestamp cluster):
|
|
2576
|
+
* 1. hs_object_id GT/EQ comparison in v3 search is NUMERIC, not lexicographic. HIGHEST STAKES — if
|
|
2577
|
+
* lexicographic, '2' > '10000' and the keyset would skip records. (hs_object_id is a sequential
|
|
2578
|
+
* 64-bit integer / number-typed property, so numeric is expected, but prove it across an
|
|
2579
|
+
* id-magnitude boundary, e.g. ids 9, 10, 100, 1000 within one timestamp cluster.)
|
|
2580
|
+
* 2. Datetime filter values accept epoch-millis-as-string for EQ/GT/GTE (the pre-existing GTE
|
|
2581
|
+
* watermark filter already relies on this, so a regression here would also break prior behavior).
|
|
2582
|
+
* 3. The compound (dateField ASC, hs_object_id ASC) sort is honored deterministically across pages
|
|
2583
|
+
* and object types, so the last raw result is the true (date,id)-max keyset boundary.
|
|
2584
|
+
* 4. `total` reflects the CURRENT filterGroups per re-anchored query (not a cached original count).
|
|
2322
2585
|
*/
|
|
2323
2586
|
async FetchChangesViaSearch(ctx) {
|
|
2324
2587
|
const companyIntegration = ctx.CompanyIntegration;
|
|
@@ -2329,36 +2592,40 @@ let HubSpotConnector = class HubSpotConnector extends BaseRESTIntegrationConnect
|
|
|
2329
2592
|
const watermarkMs = new Date(ctx.WatermarkValue).getTime();
|
|
2330
2593
|
const properties = this.BuildEffectiveProperties(ctx.ObjectName, ctx.RequestedSourceFields);
|
|
2331
2594
|
const pageSize = Math.min(ctx.BatchSize ?? 100, 100); // HubSpot search API max is 100
|
|
2595
|
+
const cursor = this.parseSearchCursor(ctx.CurrentCursor);
|
|
2596
|
+
const isReanchored = cursor.anchorDateMs != null && cursor.anchorId != null;
|
|
2597
|
+
// When re-anchored past a 10k window, the keyset predicate replaces the plain GTE filter.
|
|
2598
|
+
// anchorDateMs is always >= watermarkMs (we only ever advance), so the keyset predicate
|
|
2599
|
+
// subsumes the original watermark filter — incremental scope is never widened.
|
|
2600
|
+
const filterGroups = isReanchored
|
|
2601
|
+
? [
|
|
2602
|
+
{ filters: [{ propertyName: dateField, operator: 'GT', value: cursor.anchorDateMs }] },
|
|
2603
|
+
{ filters: [
|
|
2604
|
+
{ propertyName: dateField, operator: 'EQ', value: cursor.anchorDateMs },
|
|
2605
|
+
{ propertyName: 'hs_object_id', operator: 'GT', value: cursor.anchorId },
|
|
2606
|
+
] },
|
|
2607
|
+
]
|
|
2608
|
+
: [{ filters: [{ propertyName: dateField, operator: 'GTE', value: String(watermarkMs) }] }];
|
|
2332
2609
|
const searchBody = {
|
|
2333
|
-
filterGroups
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
}
|
|
2340
|
-
|
|
2610
|
+
filterGroups,
|
|
2611
|
+
// Secondary sort on hs_object_id is REQUIRED: it makes ordering deterministic within a
|
|
2612
|
+
// same-timestamp cluster so the keyset anchor (last record's id) is well-defined and the
|
|
2613
|
+
// next window can never skip or re-emit a record at the boundary.
|
|
2614
|
+
sorts: [
|
|
2615
|
+
{ propertyName: dateField, direction: 'ASCENDING' },
|
|
2616
|
+
{ propertyName: 'hs_object_id', direction: 'ASCENDING' },
|
|
2617
|
+
],
|
|
2341
2618
|
properties,
|
|
2342
2619
|
limit: pageSize,
|
|
2343
2620
|
};
|
|
2344
|
-
if (
|
|
2345
|
-
searchBody['after'] =
|
|
2621
|
+
if (cursor.after) {
|
|
2622
|
+
searchBody['after'] = cursor.after;
|
|
2346
2623
|
}
|
|
2347
2624
|
const url = `${HUBSPOT_API_BASE}/crm/v3/objects/${ctx.ObjectName}/search`;
|
|
2348
2625
|
const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, searchBody);
|
|
2349
2626
|
this.ValidateCRUDResponse(response, 'FetchChangesViaSearch', ctx.ObjectName);
|
|
2350
2627
|
const body = response.Body;
|
|
2351
|
-
// HubSpot Search API hard limit: 10,000 results maximum per query window.
|
|
2352
|
-
// If total > 10K, only the oldest-modified records (sorted ASCENDING) are returned.
|
|
2353
|
-
// The watermark advances to the batch's max date each cycle, so subsequent syncs
|
|
2354
|
-
// pick up the remainder — no records are permanently lost, but multiple sync cycles
|
|
2355
|
-
// are needed to catch up. Log a warning so operators can monitor.
|
|
2356
2628
|
const searchTotal = body.total ?? 0;
|
|
2357
|
-
if (searchTotal > 10_000) {
|
|
2358
|
-
console.warn(`[HubSpot] ${ctx.ObjectName}: Search API returned total=${searchTotal} but is capped at 10,000 results. ` +
|
|
2359
|
-
`Sync will require ${Math.ceil(searchTotal / 10_000)} cycles to catch up from watermark ${ctx.WatermarkValue}. ` +
|
|
2360
|
-
`Consider reducing the sync interval or enabling continuous sync for this object.`);
|
|
2361
|
-
}
|
|
2362
2629
|
const rawResults = body.results ?? [];
|
|
2363
2630
|
const records = rawResults.map(r => {
|
|
2364
2631
|
const raw = r;
|
|
@@ -2369,10 +2636,32 @@ let HubSpotConnector = class HubSpotConnector extends BaseRESTIntegrationConnect
|
|
|
2369
2636
|
Fields: flat,
|
|
2370
2637
|
};
|
|
2371
2638
|
});
|
|
2372
|
-
|
|
2373
|
-
const
|
|
2374
|
-
|
|
2375
|
-
|
|
2639
|
+
// Keyset anchor for the NEXT window = the last record in (date, id) sort order.
|
|
2640
|
+
const anchor = this.extractSearchAnchor(rawResults, dateField);
|
|
2641
|
+
const { nextCursor, hasMore, stalled } = this.computeSearchResume({
|
|
2642
|
+
incoming: cursor,
|
|
2643
|
+
pagingNextAfter: body.paging?.next?.after,
|
|
2644
|
+
total: searchTotal,
|
|
2645
|
+
lastAnchorDateMs: anchor.dateMs,
|
|
2646
|
+
lastAnchorId: anchor.id,
|
|
2647
|
+
});
|
|
2648
|
+
if (stalled) {
|
|
2649
|
+
// total exceeds the window cap but we couldn't form a keyset anchor to page past it. Fail
|
|
2650
|
+
// LOUD rather than silently dropping the remainder: the engine catches this, marks the
|
|
2651
|
+
// fetch incomplete, and leaves the watermark un-advanced so the next sync retries the gap.
|
|
2652
|
+
throw new Error(`HubSpot ${ctx.ObjectName}: search reported total=${searchTotal} (beyond the ` +
|
|
2653
|
+
`${HUBSPOT_SEARCH_WINDOW_CAP}-record window cap) but the last page returned no keyset anchor, ` +
|
|
2654
|
+
`so the scan cannot advance without risking silent record loss. Aborting this fetch; the ` +
|
|
2655
|
+
`watermark is left un-advanced and the remainder is retried on the next sync.`);
|
|
2656
|
+
}
|
|
2657
|
+
if (searchTotal > HUBSPOT_SEARCH_WINDOW_CAP && !cursor.after && !isReanchored) {
|
|
2658
|
+
// Informational only — the keyset re-anchor below pages through the whole window in this
|
|
2659
|
+
// sync; this is no longer a multi-cycle stall, just a large object worth noting.
|
|
2660
|
+
console.log(`[HubSpot] ${ctx.ObjectName}: ${searchTotal} records since watermark exceed the 10k search window; ` +
|
|
2661
|
+
`keyset-paginating by (${dateField}, hs_object_id) to fetch them all in this sync.`);
|
|
2662
|
+
}
|
|
2663
|
+
// On the final page of the entire scan, also fetch archived (deleted) records since the
|
|
2664
|
+
// watermark so they flow through the engine's delete pipeline.
|
|
2376
2665
|
if (!hasMore) {
|
|
2377
2666
|
const archived = await this.FetchArchivedCRMChanges(auth, headers, ctx.ObjectName, dateField, watermarkMs, properties);
|
|
2378
2667
|
if (archived.length > 0) {
|
|
@@ -2389,10 +2678,103 @@ let HubSpotConnector = class HubSpotConnector extends BaseRESTIntegrationConnect
|
|
|
2389
2678
|
return {
|
|
2390
2679
|
Records: records,
|
|
2391
2680
|
HasMore: hasMore,
|
|
2392
|
-
NextCursor: nextCursor,
|
|
2681
|
+
NextCursor: nextCursor ? JSON.stringify(nextCursor) : undefined,
|
|
2393
2682
|
NewWatermarkValue: newWatermark,
|
|
2394
2683
|
};
|
|
2395
2684
|
}
|
|
2685
|
+
/**
|
|
2686
|
+
* Parses the {@link HubSpotSearchCursor} threaded via FetchContext.CurrentCursor. Tolerates a
|
|
2687
|
+
* legacy raw `after` string (pre-keyset format) by treating it as a plain window offset, so an
|
|
2688
|
+
* in-flight sync mid-upgrade degrades gracefully rather than throwing.
|
|
2689
|
+
*/
|
|
2690
|
+
parseSearchCursor(raw) {
|
|
2691
|
+
if (!raw)
|
|
2692
|
+
return {};
|
|
2693
|
+
try {
|
|
2694
|
+
const parsed = JSON.parse(raw);
|
|
2695
|
+
if (parsed && typeof parsed === 'object')
|
|
2696
|
+
return parsed;
|
|
2697
|
+
// Parsed to a primitive (e.g. a bare numeric `after` like "9900" from the pre-keyset
|
|
2698
|
+
// format) — treat the raw value as a plain window offset.
|
|
2699
|
+
return { after: raw };
|
|
2700
|
+
}
|
|
2701
|
+
catch {
|
|
2702
|
+
return { after: raw };
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
/**
|
|
2706
|
+
* Extracts the keyset anchor (the last record's dateField-as-epoch-ms and hs_object_id) from a
|
|
2707
|
+
* batch of raw search results. Because results are sorted (dateField, hs_object_id) ASCENDING,
|
|
2708
|
+
* the last element is the maximum position and therefore the resume point for the next window.
|
|
2709
|
+
* Returns undefined fields when the batch is empty or the values can't be parsed.
|
|
2710
|
+
*/
|
|
2711
|
+
extractSearchAnchor(rawResults, dateField) {
|
|
2712
|
+
if (rawResults.length === 0)
|
|
2713
|
+
return {};
|
|
2714
|
+
const lastFlat = this.FlattenHubSpotRecord(rawResults[rawResults.length - 1]);
|
|
2715
|
+
const id = lastFlat['hs_object_id'];
|
|
2716
|
+
const result = {};
|
|
2717
|
+
if (id != null && String(id).length > 0)
|
|
2718
|
+
result.id = String(id);
|
|
2719
|
+
const dateMs = this.toEpochMs(lastFlat[dateField]);
|
|
2720
|
+
if (dateMs != null)
|
|
2721
|
+
result.dateMs = dateMs;
|
|
2722
|
+
return result;
|
|
2723
|
+
}
|
|
2724
|
+
/**
|
|
2725
|
+
* Normalizes a HubSpot datetime property value to an epoch-millis string for use in a search
|
|
2726
|
+
* filter. Accepts both an ISO-8601 string (the usual v3 shape) and a bare epoch-millis numeric
|
|
2727
|
+
* string (some endpoints/properties). Returns undefined when unparseable — callers then skip
|
|
2728
|
+
* re-anchoring rather than seeking from a NaN position.
|
|
2729
|
+
*/
|
|
2730
|
+
toEpochMs(dateVal) {
|
|
2731
|
+
if (dateVal == null)
|
|
2732
|
+
return undefined;
|
|
2733
|
+
const s = String(dateVal);
|
|
2734
|
+
const iso = new Date(s).getTime();
|
|
2735
|
+
if (!Number.isNaN(iso))
|
|
2736
|
+
return String(iso);
|
|
2737
|
+
const epoch = Number(s);
|
|
2738
|
+
if (!Number.isNaN(epoch) && epoch > 0)
|
|
2739
|
+
return String(epoch);
|
|
2740
|
+
return undefined;
|
|
2741
|
+
}
|
|
2742
|
+
/**
|
|
2743
|
+
* Pure decision for the next search window, given this page's pagination + total. No network.
|
|
2744
|
+
*
|
|
2745
|
+
* - If more pages remain inside the current ≤10k window, advance the API `after` offset and keep
|
|
2746
|
+
* the same anchor.
|
|
2747
|
+
* - Otherwise the window is exhausted (the API stopped returning `after`, or it reached the 10k
|
|
2748
|
+
* cap). If records still match the current filter (`total > cap`), re-anchor the next window on
|
|
2749
|
+
* the last record's (dateField, hs_object_id) keyset; else the scan is complete.
|
|
2750
|
+
*
|
|
2751
|
+
* `total` is the count matching the CURRENT filter, so after each re-anchor it shrinks by roughly
|
|
2752
|
+
* one window until it falls to/under the cap — guaranteeing termination with no skipped records
|
|
2753
|
+
* (the anchor's id strictly increases) and no duplicates (the keyset predicate excludes it).
|
|
2754
|
+
*/
|
|
2755
|
+
computeSearchResume(args) {
|
|
2756
|
+
const { incoming, pagingNextAfter, total, lastAnchorDateMs, lastAnchorId } = args;
|
|
2757
|
+
const cap = HUBSPOT_SEARCH_WINDOW_CAP;
|
|
2758
|
+
const withinWindow = pagingNextAfter != null && Number(pagingNextAfter) < cap;
|
|
2759
|
+
if (withinWindow) {
|
|
2760
|
+
return {
|
|
2761
|
+
nextCursor: { after: pagingNextAfter, anchorDateMs: incoming.anchorDateMs, anchorId: incoming.anchorId },
|
|
2762
|
+
hasMore: true,
|
|
2763
|
+
};
|
|
2764
|
+
}
|
|
2765
|
+
if (total > cap) {
|
|
2766
|
+
if (lastAnchorId != null && lastAnchorDateMs != null) {
|
|
2767
|
+
return { nextCursor: { anchorDateMs: lastAnchorDateMs, anchorId: lastAnchorId }, hasMore: true };
|
|
2768
|
+
}
|
|
2769
|
+
// total exceeds the 10k window cap but this page produced no usable keyset anchor — which
|
|
2770
|
+
// means HubSpot returned a total > 0 with an empty/anchorless page (a contract violation).
|
|
2771
|
+
// Re-anchoring is impossible; silently stopping here would DROP the remaining records — the
|
|
2772
|
+
// exact silent-loss this fix exists to prevent. Flag it so the caller aborts loudly and
|
|
2773
|
+
// leaves the watermark un-advanced (the next sync retries the gap) instead of skipping it.
|
|
2774
|
+
return { nextCursor: undefined, hasMore: false, stalled: true };
|
|
2775
|
+
}
|
|
2776
|
+
return { nextCursor: undefined, hasMore: false };
|
|
2777
|
+
}
|
|
2396
2778
|
/**
|
|
2397
2779
|
* Detects archived (deleted) CRM records since the given watermark.
|
|
2398
2780
|
*
|
|
@@ -2418,7 +2800,13 @@ let HubSpotConnector = class HubSpotConnector extends BaseRESTIntegrationConnect
|
|
|
2418
2800
|
value: String(watermarkMs),
|
|
2419
2801
|
}],
|
|
2420
2802
|
}],
|
|
2421
|
-
|
|
2803
|
+
// Secondary sort on hs_object_id matches the active-record path: bulk deletions often
|
|
2804
|
+
// share one hs_lastmodifieddate, so without a tie-breaker the opaque `after` pages of
|
|
2805
|
+
// this archived scan have undefined intra-cluster order and could skip/duplicate.
|
|
2806
|
+
sorts: [
|
|
2807
|
+
{ propertyName: dateField, direction: 'ASCENDING' },
|
|
2808
|
+
{ propertyName: 'hs_object_id', direction: 'ASCENDING' },
|
|
2809
|
+
],
|
|
2422
2810
|
properties,
|
|
2423
2811
|
limit: 100,
|
|
2424
2812
|
archived: true,
|
|
@@ -2478,7 +2866,14 @@ let HubSpotConnector = class HubSpotConnector extends BaseRESTIntegrationConnect
|
|
|
2478
2866
|
const parsed = this.ParseAssociationPath(obj.APIPath);
|
|
2479
2867
|
if (!parsed) {
|
|
2480
2868
|
console.warn(`[HubSpot] Cannot parse association path: ${obj.APIPath}`);
|
|
2481
|
-
return {
|
|
2869
|
+
return {
|
|
2870
|
+
Records: [], HasMore: false,
|
|
2871
|
+
Warnings: [{
|
|
2872
|
+
Code: 'ASSOCIATION_PATH_UNPARSEABLE',
|
|
2873
|
+
Message: `Association '${obj.Name}' has an unparseable APIPath '${obj.APIPath}' — cannot determine the from/to object types, so 0 associations were fetched (not a real "no data" result).`,
|
|
2874
|
+
Data: { object: obj.Name, apiPath: obj.APIPath },
|
|
2875
|
+
}],
|
|
2876
|
+
};
|
|
2482
2877
|
}
|
|
2483
2878
|
const { fromType, toType } = parsed;
|
|
2484
2879
|
const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
|
|
@@ -2490,6 +2885,23 @@ let HubSpotConnector = class HubSpotConnector extends BaseRESTIntegrationConnect
|
|
|
2490
2885
|
const parentIDs = await this.LoadAssociationParentIDs(fromType, ctx);
|
|
2491
2886
|
const parentOffset = ctx.CurrentOffset ?? 0;
|
|
2492
2887
|
const BATCH_LIMIT = 100; // HubSpot v4 batch/read max inputs per request
|
|
2888
|
+
// Zero parents on the FIRST page = the silent-empty case for a second-layer object: there is
|
|
2889
|
+
// no parent `fromType` data in MJ to read associations from (parent not synced/mapped, or its
|
|
2890
|
+
// entity-map disabled, or DAG ordered this before its parent). Surface it as a structured
|
|
2891
|
+
// FetchWarning so the engine records it in the run artifact (visible over GraphQL) instead
|
|
2892
|
+
// of a quiet zero-record "success". Subsequent pages legitimately exhaust to 0 — not flagged.
|
|
2893
|
+
if (parentOffset === 0 && parentIDs.length === 0) {
|
|
2894
|
+
console.warn(`[HubSpot] Association '${obj.Name}': 0 parent ${fromType} records in MJ — nothing to associate.`);
|
|
2895
|
+
return {
|
|
2896
|
+
Records: [],
|
|
2897
|
+
HasMore: false,
|
|
2898
|
+
Warnings: [{
|
|
2899
|
+
Code: 'ZERO_PARENTS',
|
|
2900
|
+
Message: `Association '${obj.Name}' has no parent ${fromType} records in MJ to read associations from — '${fromType}' was not synced/mapped this run (or its entity-map is disabled). 0 associations fetched.`,
|
|
2901
|
+
Data: { object: obj.Name, parentType: fromType },
|
|
2902
|
+
}],
|
|
2903
|
+
};
|
|
2904
|
+
}
|
|
2493
2905
|
if (parentOffset === 0) {
|
|
2494
2906
|
console.log(`[HubSpot] Fetching ${obj.Name}: ${parentIDs.length} parent ${fromType} via batch API (100/request)`);
|
|
2495
2907
|
}
|
|
@@ -2515,8 +2927,10 @@ let HubSpotConnector = class HubSpotConnector extends BaseRESTIntegrationConnect
|
|
|
2515
2927
|
if (response.Status < 200 || response.Status >= 300) {
|
|
2516
2928
|
const respBody = response.Body;
|
|
2517
2929
|
const msg = respBody?.message ?? respBody?.error ?? JSON.stringify(respBody);
|
|
2518
|
-
|
|
2519
|
-
|
|
2930
|
+
// Surface as a real error (the engine's fetch handler records it as sync.record.error in
|
|
2931
|
+
// the run artifact) instead of a swallowed console.warn + empty result that reads as a
|
|
2932
|
+
// successful "no associations" — a non-2xx batch/read is a failure, not absence of data.
|
|
2933
|
+
throw new Error(`[HubSpot] Association batch read failed for ${objectName}: HTTP ${response.Status} — ${msg}`);
|
|
2520
2934
|
}
|
|
2521
2935
|
const respBody = response.Body;
|
|
2522
2936
|
const records = [];
|
|
@@ -2605,6 +3019,14 @@ let HubSpotConnector = class HubSpotConnector extends BaseRESTIntegrationConnect
|
|
|
2605
3019
|
const obj = HUBSPOT_OBJECTS.find(o => o.Name === objectName);
|
|
2606
3020
|
return obj ? obj.Fields.map(f => f.Name) : [];
|
|
2607
3021
|
}
|
|
3022
|
+
/**
|
|
3023
|
+
* Returns the configured upsert key (unique business property to match on) for an
|
|
3024
|
+
* object from HUBSPOT_OBJECTS metadata, or undefined if the object declares none.
|
|
3025
|
+
* Used by Upsert to default the idProperty when the caller doesn't override it.
|
|
3026
|
+
*/
|
|
3027
|
+
GetUpsertKey(objectName) {
|
|
3028
|
+
return HUBSPOT_OBJECTS.find(o => o.Name === objectName)?.UpsertKey;
|
|
3029
|
+
}
|
|
2608
3030
|
/**
|
|
2609
3031
|
* Returns the effective property list for a HubSpot CRM request.
|
|
2610
3032
|
*
|