@memberjunction/integration-connectors 5.38.0 → 5.40.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.
Files changed (67) hide show
  1. package/dist/AptifyConnector.d.ts +7 -0
  2. package/dist/AptifyConnector.d.ts.map +1 -1
  3. package/dist/AptifyConnector.js +34 -11
  4. package/dist/AptifyConnector.js.map +1 -1
  5. package/dist/BlackbaudConnector.js +1 -1
  6. package/dist/BlackbaudConnector.js.map +1 -1
  7. package/dist/ConstantContactConnector.d.ts +12 -1
  8. package/dist/ConstantContactConnector.d.ts.map +1 -1
  9. package/dist/ConstantContactConnector.js +32 -5
  10. package/dist/ConstantContactConnector.js.map +1 -1
  11. package/dist/HubSpotConnector.d.ts +162 -2
  12. package/dist/HubSpotConnector.d.ts.map +1 -1
  13. package/dist/HubSpotConnector.js +466 -44
  14. package/dist/HubSpotConnector.js.map +1 -1
  15. package/dist/IMISConnector.d.ts.map +1 -1
  16. package/dist/IMISConnector.js +1 -5
  17. package/dist/IMISConnector.js.map +1 -1
  18. package/dist/MagnetMailConnector.d.ts.map +1 -1
  19. package/dist/MagnetMailConnector.js +8 -1
  20. package/dist/MagnetMailConnector.js.map +1 -1
  21. package/dist/MailchimpConnector.d.ts +10 -1
  22. package/dist/MailchimpConnector.d.ts.map +1 -1
  23. package/dist/MailchimpConnector.js +39 -1
  24. package/dist/MailchimpConnector.js.map +1 -1
  25. package/dist/NetForumConnector.d.ts +27 -1
  26. package/dist/NetForumConnector.d.ts.map +1 -1
  27. package/dist/NetForumConnector.js +88 -2
  28. package/dist/NetForumConnector.js.map +1 -1
  29. package/dist/NimbleAMSConnector.d.ts.map +1 -1
  30. package/dist/NimbleAMSConnector.js +2 -1
  31. package/dist/NimbleAMSConnector.js.map +1 -1
  32. package/dist/PropFuelConnector.d.ts +11 -0
  33. package/dist/PropFuelConnector.d.ts.map +1 -1
  34. package/dist/PropFuelConnector.js +47 -10
  35. package/dist/PropFuelConnector.js.map +1 -1
  36. package/dist/QuickBooksConnector.d.ts.map +1 -1
  37. package/dist/QuickBooksConnector.js +30 -10
  38. package/dist/QuickBooksConnector.js.map +1 -1
  39. package/dist/RasaConnector.d.ts +9 -1
  40. package/dist/RasaConnector.d.ts.map +1 -1
  41. package/dist/RasaConnector.js +27 -0
  42. package/dist/RasaConnector.js.map +1 -1
  43. package/dist/Reach360Connector.d.ts +11 -1
  44. package/dist/Reach360Connector.d.ts.map +1 -1
  45. package/dist/Reach360Connector.js +96 -10
  46. package/dist/Reach360Connector.js.map +1 -1
  47. package/dist/SageIntacctConnector.js +1 -1
  48. package/dist/SageIntacctConnector.js.map +1 -1
  49. package/dist/SalesforceConnector.d.ts +21 -0
  50. package/dist/SalesforceConnector.d.ts.map +1 -1
  51. package/dist/SalesforceConnector.js +64 -2
  52. package/dist/SalesforceConnector.js.map +1 -1
  53. package/dist/SharePointConnector.js +1 -1
  54. package/dist/SharePointConnector.js.map +1 -1
  55. package/dist/WicketConnector.d.ts +12 -1
  56. package/dist/WicketConnector.d.ts.map +1 -1
  57. package/dist/WicketConnector.js +73 -2
  58. package/dist/WicketConnector.js.map +1 -1
  59. package/dist/WildApricotConnector.d.ts +13 -0
  60. package/dist/WildApricotConnector.d.ts.map +1 -1
  61. package/dist/WildApricotConnector.js +47 -10
  62. package/dist/WildApricotConnector.js.map +1 -1
  63. package/dist/YourMembershipConnector.d.ts +26 -0
  64. package/dist/YourMembershipConnector.d.ts.map +1 -1
  65. package/dist/YourMembershipConnector.js +49 -6
  66. package/dist/YourMembershipConnector.js.map +1 -1
  67. package/package.json +6 -6
@@ -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
- { name: 'assoc_contacts_deals', label: 'Contact ↔ Deal', description: 'Associations between contacts and deals', apiPath: '/crm/v4/associations/contacts/deals', pkFields: ['contact_id', 'deal_id'] },
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
- { name: 'assoc_companies_deals', label: 'Company ↔ Deal', description: 'Associations between companies and deals', apiPath: '/crm/v4/associations/companies/deals', pkFields: ['company_id', 'deal_id'] },
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 unique key — HubSpot's Properties API sets
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
- // so we must override it regardless of what the API reports.
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
- const pathParts = assocConfig.apiPath.split('/').filter(Boolean);
1537
- const fromType = pathParts[pathParts.length - 2];
1538
- const toType = pathParts[pathParts.length - 1];
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: leftID }, to: { id: rightID }, types: [] }] };
1679
+ const body = { inputs: [{ from: { id: fromID }, to: { id: toID }, types }] };
1541
1680
  const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, body);
1542
- if (response.Status >= 200 && response.Status < 300) {
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 this.BuildCRUDErrorResult(response, 'CreateAssociation', objectName);
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
- const pathParts = assocConfig.apiPath.split('/').filter(Boolean);
1568
- const fromType = pathParts[pathParts.length - 2];
1569
- const toType = pathParts[pathParts.length - 1];
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: leftID }, to: { id: rightID } }] };
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
- throw new Error(`HubSpot API request failed after ${maxRetries} retries: ${url}`);
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
- filters: [{
2335
- propertyName: dateField,
2336
- operator: 'GTE',
2337
- value: String(watermarkMs),
2338
- }],
2339
- }],
2340
- sorts: [{ propertyName: dateField, direction: 'ASCENDING' }],
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 (ctx.CurrentCursor) {
2345
- searchBody['after'] = ctx.CurrentCursor;
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
- const nextCursor = body.paging?.next?.after;
2373
- const hasMore = nextCursor != null;
2374
- // On the final page of active records, also fetch archived (deleted) records
2375
- // since the watermark so they flow through the engine's delete pipeline.
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
- sorts: [{ propertyName: dateField, direction: 'ASCENDING' }],
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 { Records: [], HasMore: false };
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
- console.warn(`[HubSpot] Association batch read failed for ${objectName}: HTTP ${response.Status} — ${msg}`);
2519
- return [];
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
  *