@memberjunction/integration-connectors 5.41.0 → 5.42.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 (92) hide show
  1. package/dist/CventConnector.d.ts +146 -0
  2. package/dist/CventConnector.d.ts.map +1 -0
  3. package/dist/CventConnector.js +540 -0
  4. package/dist/CventConnector.js.map +1 -0
  5. package/dist/DynamicsDataverseConnector.d.ts +296 -0
  6. package/dist/DynamicsDataverseConnector.d.ts.map +1 -0
  7. package/dist/DynamicsDataverseConnector.js +790 -0
  8. package/dist/DynamicsDataverseConnector.js.map +1 -0
  9. package/dist/FontevaConnector.d.ts +153 -0
  10. package/dist/FontevaConnector.d.ts.map +1 -0
  11. package/dist/FontevaConnector.js +421 -0
  12. package/dist/FontevaConnector.js.map +1 -0
  13. package/dist/GrowthZoneConnector.d.ts +123 -125
  14. package/dist/GrowthZoneConnector.d.ts.map +1 -1
  15. package/dist/GrowthZoneConnector.js +456 -499
  16. package/dist/GrowthZoneConnector.js.map +1 -1
  17. package/dist/HivebriteConnector.d.ts +151 -0
  18. package/dist/HivebriteConnector.d.ts.map +1 -0
  19. package/dist/HivebriteConnector.js +533 -0
  20. package/dist/HivebriteConnector.js.map +1 -0
  21. package/dist/IMISConnector.d.ts +112 -96
  22. package/dist/IMISConnector.d.ts.map +1 -1
  23. package/dist/IMISConnector.js +430 -417
  24. package/dist/IMISConnector.js.map +1 -1
  25. package/dist/MemberSuiteConnector.d.ts +185 -0
  26. package/dist/MemberSuiteConnector.d.ts.map +1 -0
  27. package/dist/MemberSuiteConnector.js +762 -0
  28. package/dist/MemberSuiteConnector.js.map +1 -0
  29. package/dist/NeonCRMConnector.d.ts +273 -0
  30. package/dist/NeonCRMConnector.d.ts.map +1 -0
  31. package/dist/NeonCRMConnector.js +1039 -0
  32. package/dist/NeonCRMConnector.js.map +1 -0
  33. package/dist/NetForumConnector.d.ts +140 -47
  34. package/dist/NetForumConnector.d.ts.map +1 -1
  35. package/dist/NetForumConnector.js +668 -395
  36. package/dist/NetForumConnector.js.map +1 -1
  37. package/dist/NetSuiteConnector.d.ts +116 -50
  38. package/dist/NetSuiteConnector.d.ts.map +1 -1
  39. package/dist/NetSuiteConnector.js +608 -510
  40. package/dist/NetSuiteConnector.js.map +1 -1
  41. package/dist/NimbleAMSConnector.d.ts +104 -11
  42. package/dist/NimbleAMSConnector.d.ts.map +1 -1
  43. package/dist/NimbleAMSConnector.js +552 -258
  44. package/dist/NimbleAMSConnector.js.map +1 -1
  45. package/dist/NoviConnector.d.ts +178 -0
  46. package/dist/NoviConnector.d.ts.map +1 -0
  47. package/dist/NoviConnector.js +566 -0
  48. package/dist/NoviConnector.js.map +1 -0
  49. package/dist/ORCIDConnector.d.ts +207 -0
  50. package/dist/ORCIDConnector.d.ts.map +1 -0
  51. package/dist/ORCIDConnector.js +708 -0
  52. package/dist/ORCIDConnector.js.map +1 -0
  53. package/dist/OpenWaterConnector.d.ts +173 -0
  54. package/dist/OpenWaterConnector.d.ts.map +1 -0
  55. package/dist/OpenWaterConnector.js +893 -0
  56. package/dist/OpenWaterConnector.js.map +1 -0
  57. package/dist/PathLMSConnector.d.ts +395 -0
  58. package/dist/PathLMSConnector.d.ts.map +1 -0
  59. package/dist/PathLMSConnector.js +1281 -0
  60. package/dist/PathLMSConnector.js.map +1 -0
  61. package/dist/PheedLoopConnector.d.ts +119 -0
  62. package/dist/PheedLoopConnector.d.ts.map +1 -0
  63. package/dist/PheedLoopConnector.js +429 -0
  64. package/dist/PheedLoopConnector.js.map +1 -0
  65. package/dist/PropFuelConnector.d.ts +135 -61
  66. package/dist/PropFuelConnector.d.ts.map +1 -1
  67. package/dist/PropFuelConnector.js +366 -467
  68. package/dist/PropFuelConnector.js.map +1 -1
  69. package/dist/RhythmConnector.d.ts +188 -0
  70. package/dist/RhythmConnector.d.ts.map +1 -0
  71. package/dist/RhythmConnector.js +784 -0
  72. package/dist/RhythmConnector.js.map +1 -0
  73. package/dist/SalesforceConnector.d.ts +69 -3
  74. package/dist/SalesforceConnector.d.ts.map +1 -1
  75. package/dist/SalesforceConnector.js +188 -166
  76. package/dist/SalesforceConnector.js.map +1 -1
  77. package/dist/SharePointConnector.d.ts +37 -30
  78. package/dist/SharePointConnector.d.ts.map +1 -1
  79. package/dist/SharePointConnector.js +163 -243
  80. package/dist/SharePointConnector.js.map +1 -1
  81. package/dist/YourMembershipConnector.d.ts.map +1 -1
  82. package/dist/YourMembershipConnector.js +13 -5
  83. package/dist/YourMembershipConnector.js.map +1 -1
  84. package/dist/index.d.ts +14 -3
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +14 -3
  87. package/dist/index.js.map +1 -1
  88. package/package.json +8 -7
  89. package/dist/BettyConnector.d.ts +0 -288
  90. package/dist/BettyConnector.d.ts.map +0 -1
  91. package/dist/BettyConnector.js +0 -909
  92. package/dist/BettyConnector.js.map +0 -1
@@ -4,548 +4,447 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
4
4
  else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
5
  return c > 3 && r && Object.defineProperty(target, key, r), r;
6
6
  };
7
- /**
8
- * PropFuelConnector — REST integration connector for PropFuel (now part of re:Members),
9
- * a personalized member-engagement platform for associations (email + SMS conversational
10
- * "Ask, Capture, Act" campaigns).
11
- *
12
- * ⚠️ STATUS: READ-ONLY SCAFFOLD.
13
- *
14
- * PropFuel does not publish a public developer portal. The connector below is built
15
- * against the *expected* shape of a tenant-scoped REST API (per-customer BaseURL +
16
- * bearer-token API key) and the documented domain objects (member profiles, campaigns,
17
- * conversations / engagements, captures / responses, segments, tags). Endpoint paths,
18
- * pagination strategy, and field names should be confirmed against PropFuel's actual
19
- * API specification before relying on this connector for production sync.
20
- *
21
- * To productionize:
22
- * 1. Obtain PropFuel API documentation (Swagger / OpenAPI / Postman collection).
23
- * 2. Verify the BaseURL pattern, auth header (Bearer vs X-API-Key), and pagination type.
24
- * 3. Replace the `PROPFUEL_OBJECT_PATHS` placeholder paths with real endpoints.
25
- * 4. Audit the field metadata in `metadata/integrations/.propfuel.json` against the spec.
26
- * 5. Add Create/Update/Delete support and flip `SupportsCreate/Update/Delete` to true.
27
- */
28
7
  import { RegisterClass } from '@memberjunction/global';
29
8
  import { Metadata } from '@memberjunction/core';
30
9
  import { BaseIntegrationConnector, BaseRESTIntegrationConnector, } from '@memberjunction/integration-engine';
31
- // ─── Constants ──────────────────────────────────────────────────────────
32
- const DEFAULT_PAGE_SIZE = 100;
33
- const DEFAULT_MAX_RETRIES = 3;
34
- const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
35
- const DEFAULT_MIN_REQUEST_INTERVAL_MS = 250;
36
- /**
37
- * Placeholder API paths. Update these once PropFuel publishes formal API docs. Each
38
- * value should be the URL path segment (no leading slash) appended to the BaseURL —
39
- * for example, `/v1/contacts` with BaseURL `https://api.propfuel.com` resolves to
40
- * `https://api.propfuel.com/v1/contacts`.
41
- */
42
- const PROPFUEL_OBJECT_PATHS = {
43
- Contacts: '/contacts',
44
- Campaigns: '/campaigns',
45
- Conversations: '/conversations',
46
- Captures: '/captures',
47
- Segments: '/segments',
48
- Tags: '/tags',
49
- };
10
+ import { z } from 'zod';
50
11
  /**
51
- * Static catalog of the PropFuel objects this connector knows how to discover.
52
- * Field metadata is intentionally minimal — DiscoverFields infers the rest from a
53
- * sample API response. This list mirrors the integration metadata in
54
- * `metadata/integrations/.propfuel.json`.
12
+ * PropFuel data-export file-feed connector.
13
+ *
14
+ * PropFuel exposes a per-tenant hourly data-export feed as a set of JSON files. There is NO
15
+ * public OpenAPI spec and NO describe/introspection endpoint, so this connector encodes the
16
+ * DISCOVERY MECHANISM, never a baked answer:
17
+ *
18
+ * - {@link DiscoverObjects} calls `GET /dataexport/{AccountID}/list` and derives each object
19
+ * from the `[microtime]-[datatype].json` filename suffix. The concrete data-type set is
20
+ * whatever the live listing actually contains — never a static `PROPFUEL_STREAMS` array.
21
+ * - {@link DiscoverFields} lists the files for a data type, downloads ONE sample file, and
22
+ * streams its records through the base {@link DiscoverFieldsViaStream} helper to derive the
23
+ * field set + data-informed soft-PK from real values — never a frozen `STREAM_FIELDS` catalog.
24
+ *
25
+ * Incremental sync uses a SYNTHETIC, FILE-LEVEL, CLIENT-SIDE cursor `__file_microtime`: the feed
26
+ * is append-only and chronologically sortable by the filename microtime prefix, so the connector
27
+ * resumes by tracking the max microtime seen across processed files (WatermarkService). The vendor
28
+ * `ack` endpoint is operational state and is NOT used as the read-only incremental cursor.
29
+ *
30
+ * READ-ONLY: the data-export feed creates/updates/deletes no source records, so all write
31
+ * capability getters stay false and no ack/POST/delete path is exercised.
32
+ *
33
+ * AccountID and the bearer Token both come from the credential (or Configuration JSON) — the
34
+ * AccountID is per-tenant config and is NEVER hardcoded.
55
35
  */
56
- const PROPFUEL_OBJECTS = [
57
- {
58
- Name: 'Contacts', DisplayName: 'Contacts',
59
- Description: 'Member / subscriber profile records — the primary identity for engagement.',
60
- SupportsWrite: false,
61
- Fields: [
62
- { Name: 'id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Contact ID' },
63
- { Name: 'email', DisplayName: 'Email', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Primary email address' },
64
- ],
65
- },
66
- {
67
- Name: 'Campaigns', DisplayName: 'Campaigns',
68
- Description: 'Engagement campaigns built from PropFuel blueprints (Ask / Capture / Act flows).',
69
- SupportsWrite: false,
70
- Fields: [
71
- { Name: 'id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Campaign ID' },
72
- ],
73
- },
74
- {
75
- Name: 'Conversations', DisplayName: 'Conversations',
76
- Description: 'Two-way member interactions across email and SMS.',
77
- SupportsWrite: false,
78
- Fields: [
79
- { Name: 'id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Conversation ID' },
80
- { Name: 'contact_id', DisplayName: 'Contact ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'FK → Contacts' },
81
- ],
82
- },
83
- {
84
- Name: 'Captures', DisplayName: 'Captures',
85
- Description: 'Member responses to Ask prompts — the data PropFuel collects from a campaign.',
86
- SupportsWrite: false,
87
- Fields: [
88
- { Name: 'id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Capture ID' },
89
- { Name: 'contact_id', DisplayName: 'Contact ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'FK → Contacts' },
90
- { Name: 'campaign_id', DisplayName: 'Campaign ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'FK → Campaigns' },
91
- ],
92
- },
93
- {
94
- Name: 'Segments', DisplayName: 'Segments',
95
- Description: 'Audience segments used to target campaigns.',
96
- SupportsWrite: false,
97
- Fields: [
98
- { Name: 'id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Segment ID' },
99
- ],
100
- },
101
- {
102
- Name: 'Tags', DisplayName: 'Tags',
103
- Description: 'Tags applied to Contacts for segmentation and targeting.',
104
- SupportsWrite: false,
105
- Fields: [
106
- { Name: 'id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Tag ID' },
107
- ],
108
- },
109
- ];
110
- const WATERMARK_CANDIDATE_FIELDS = ['updated_at', 'modified_at', 'last_updated', 'updatedAt'];
111
- // ─── Connector ──────────────────────────────────────────────────────────
112
36
  let PropFuelConnector = class PropFuelConnector extends BaseRESTIntegrationConnector {
113
- constructor() {
114
- super(...arguments);
115
- this.authState = null;
116
- this.lastRequestTime = 0;
37
+ /** Verbatim three-way invariant name: ClassName / IntegrationName getter / MJ: Integrations.Name. */
38
+ get IntegrationName() {
39
+ return 'PropFuel';
117
40
  }
118
- // ── Capability flags ────────────────────────────────────────────
119
- // Read-only initial cut. Flip to true once PropFuel write endpoints are confirmed
120
- // and the corresponding CreateRecord / UpdateRecord / DeleteRecord overrides are added.
121
- get IntegrationName() { return 'PropFuel'; }
122
- get SupportsCreate() { return false; }
123
- get SupportsUpdate() { return false; }
124
- get SupportsDelete() { return false; }
125
- get SupportsSearch() { return false; }
126
- get SupportsListing() { return true; }
127
- GetIntegrationObjects() {
128
- return PROPFUEL_OBJECTS;
41
+ // ─── Capability surface (read-only file feed) ────────────────────
42
+ // SupportsCreate/Update/Delete stay false (BaseIntegrationConnector defaults). The data-export
43
+ // feed is a pull-only source; the ack endpoint is operational and is not modelled as a write.
44
+ /**
45
+ * KEYSET / no-watermark resume hint: every PropFuel object is a data-export file stream ordered
46
+ * by the filename microtime prefix, so 'microtime' is the stable, monotonic ordering key for all
47
+ * of them (the docs-provable feed object and every runtime-discovered data-type stream alike).
48
+ */
49
+ StableOrderingKey(_objectName) {
50
+ return MICROTIME_ORDERING_KEY;
129
51
  }
130
- GetActionGeneratorConfig() {
131
- const config = super.GetActionGeneratorConfig();
132
- if (!config)
133
- return null;
134
- config.IconClass = 'fa-solid fa-comments';
135
- config.CategoryDescription = 'PropFuel member-engagement platform — read-only access to contacts, campaigns, conversations, and captures.';
136
- config.ParentCategoryName = 'Engagement';
137
- config.IncludeSearch = false;
138
- config.IncludeList = true;
139
- return config;
52
+ /**
53
+ * The file-feed microtime IS a reliable monotonic high-water mark: PropFuel emits export files in
54
+ * ascending microtime order, and an updated record re-appears in a NEW (higher-microtime) file. So
55
+ * `NewWatermarkValue` (= max microtime seen) is the true global max — the engine can NARROW the next
56
+ * incremental to `microtime > watermark` instead of clearing the keyset position and re-scanning the
57
+ * whole stream every run. Without this, a clean sync's keyset-resume marker is cleared and every
58
+ * incremental re-walks the entire object (correct via content-hash, but wasteful at scale).
59
+ */
60
+ get MonotonicWatermark() {
61
+ return true;
140
62
  }
141
- // ── Auth + transport (abstract methods from BaseRESTIntegrationConnector) ──
63
+ // ─── Auth + transport (BaseRESTIntegrationConnector abstracts) ────
64
+ /**
65
+ * Resolves the bearer Token + per-tenant AccountID from the linked Credential entity, falling
66
+ * back to the CompanyIntegration.Configuration JSON. The credential bytes never leave this scope.
67
+ */
142
68
  async Authenticate(companyIntegration, contextUser) {
143
- return this.GetAuth(companyIntegration, contextUser);
69
+ const creds = await this.LoadCredentials(companyIntegration, contextUser);
70
+ return { Token: creds.Token, AccountID: creds.AccountID, BaseHost: creds.BaseHost };
144
71
  }
72
+ /** Static Bearer header. No crypto/signing is required for a static token, so no auth-helper is used. */
145
73
  BuildHeaders(auth) {
146
74
  return {
147
75
  'Authorization': `Bearer ${auth.Token}`,
148
76
  'Accept': 'application/json',
149
- 'User-Agent': 'MemberJunction-Integration/1.0',
150
77
  };
151
78
  }
152
- async MakeHTTPRequest(auth, url, method, headers, body) {
153
- const pfAuth = auth;
154
- const config = pfAuth.Config;
155
- const maxRetries = config.MaxRetries ?? DEFAULT_MAX_RETRIES;
156
- const timeoutMs = config.RequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
157
- const minInterval = config.MinRequestIntervalMs ?? DEFAULT_MIN_REQUEST_INTERVAL_MS;
158
- const effectiveHeaders = { ...headers };
159
- if (body !== undefined && method !== 'GET' && method !== 'DELETE' && !effectiveHeaders['Content-Type']) {
160
- effectiveHeaders['Content-Type'] = 'application/json';
79
+ /** Base URL for the data-export feed, tenant-scoped by the resolved AccountID. */
80
+ GetBaseURL(_companyIntegration, auth) {
81
+ return `${auth.BaseHost ?? PROPFUEL_HOST}/dataexport/${encodeURIComponent(auth.AccountID)}`;
82
+ }
83
+ /**
84
+ * Executes an HTTP request via fetch. Parses JSON when the response is JSON, else returns the
85
+ * raw text body. The concrete connector owns the transport seam so tests can override it.
86
+ */
87
+ async MakeHTTPRequest(_auth, url, method, headers, body) {
88
+ const init = { method, headers };
89
+ if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
90
+ init.body = typeof body === 'string' ? body : JSON.stringify(body);
91
+ init.headers['Content-Type'] = 'application/json';
161
92
  }
162
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
163
- await this.Throttle(minInterval);
164
- const response = await this.ExecuteFetch(url, method, effectiveHeaders, body, timeoutMs);
165
- this.lastRequestTime = Date.now();
166
- if (this.ShouldRetry(response.status) && attempt < maxRetries) {
167
- await this.Sleep(this.ComputeRetryDelay(response, attempt));
168
- continue;
93
+ const response = await fetch(url, init);
94
+ const responseHeaders = {};
95
+ response.headers.forEach((value, key) => { responseHeaders[key.toLowerCase()] = value; });
96
+ const text = await response.text();
97
+ let parsed = text;
98
+ const contentType = responseHeaders['content-type'] ?? '';
99
+ if (contentType.includes('json') || (text.length > 0 && (text[0] === '{' || text[0] === '['))) {
100
+ try {
101
+ parsed = JSON.parse(text);
102
+ }
103
+ catch {
104
+ parsed = text;
169
105
  }
170
- return this.BuildRESTResponse(response);
171
106
  }
172
- throw new Error(`PropFuel API request failed after ${maxRetries} attempt(s): ${url}`);
107
+ return { Status: response.status, Body: parsed, Headers: responseHeaders };
173
108
  }
109
+ /**
110
+ * The download response for a data-export file is a JSON array of records (no envelope). When a
111
+ * vendor wraps records under a key, that key is honored; otherwise a root array / single object
112
+ * is normalized to an array.
113
+ */
174
114
  NormalizeResponse(rawBody, responseDataKey) {
175
- if (rawBody == null)
176
- return [];
177
- if (Array.isArray(rawBody))
178
- return rawBody;
179
- const body = rawBody;
180
- if (responseDataKey && Array.isArray(body[responseDataKey])) {
181
- return body[responseDataKey];
115
+ if (responseDataKey && rawBody && typeof rawBody === 'object' && !Array.isArray(rawBody)) {
116
+ const inner = rawBody[responseDataKey];
117
+ if (Array.isArray(inner))
118
+ return inner.filter(isRecord);
182
119
  }
183
- // Common envelope conventions — pick whichever the live API actually uses.
184
- for (const key of ['data', 'items', 'results', 'records']) {
185
- if (Array.isArray(body[key]))
186
- return body[key];
187
- }
188
- if (Object.keys(body).length > 0)
189
- return [body];
120
+ if (Array.isArray(rawBody))
121
+ return rawBody.filter(isRecord);
122
+ if (isRecord(rawBody))
123
+ return [rawBody];
190
124
  return [];
191
125
  }
192
- ExtractPaginationInfo(rawBody, paginationType, currentPage, currentOffset, pageSize) {
193
- if (paginationType === 'None')
194
- return { HasMore: false };
195
- const records = this.NormalizeResponse(rawBody, null);
196
- const effectivePageSize = pageSize > 0 ? pageSize : DEFAULT_PAGE_SIZE;
197
- const hasMore = records.length >= effectivePageSize;
198
- switch (paginationType) {
199
- case 'Offset':
200
- return { HasMore: hasMore, NextOffset: currentOffset + records.length };
201
- case 'PageNumber':
202
- return { HasMore: hasMore, NextPage: currentPage + 1 };
203
- case 'Cursor': {
204
- const body = rawBody;
205
- const cursor = body?.['next_cursor'] ?? body?.['cursor'] ?? body?.['nextCursor'];
206
- return {
207
- HasMore: !!cursor,
208
- NextCursor: typeof cursor === 'string' ? cursor : undefined,
209
- };
210
- }
211
- default:
212
- return { HasMore: hasMore };
213
- }
214
- }
215
- GetBaseURL(_companyIntegration, auth) {
216
- return auth.BaseURL;
126
+ /**
127
+ * The file feed has no in-file pagination — each downloaded file is read whole. File-level
128
+ * paging across the listing is handled by {@link FetchChanges} via the microtime cursor.
129
+ */
130
+ ExtractPaginationInfo(_rawBody, _paginationType, _currentPage, _currentOffset, _pageSize) {
131
+ return { HasMore: false };
217
132
  }
218
- // ── Discovery + connection test ─────────────────────────────────
133
+ /** Tests connectivity by listing the tenant's available data-export files. */
219
134
  async TestConnection(companyIntegration, contextUser) {
220
135
  try {
221
- const auth = await this.GetAuth(companyIntegration, contextUser, true);
222
- const path = PROPFUEL_OBJECT_PATHS['Contacts'];
223
- const url = `${auth.BaseURL}${path}?limit=1`;
224
- const response = await this.MakeHTTPRequest(auth, url, 'GET', this.BuildHeaders(auth));
225
- if (response.Status >= 200 && response.Status < 300) {
226
- return { Success: true, Message: `Connected to PropFuel at ${auth.BaseURL}` };
136
+ const auth = await this.Authenticate(companyIntegration, contextUser);
137
+ const response = await this.ListFiles(auth);
138
+ if (response.Status === 401 || response.Status === 403) {
139
+ return { Success: false, Message: `Authentication failed (HTTP ${response.Status}) listing PropFuel data-export files.` };
227
140
  }
228
- return {
229
- Success: false,
230
- Message: `PropFuel responded HTTP ${response.Status}. The endpoint shape may differ from the placeholder used here — verify against PropFuel's API documentation.`,
231
- };
141
+ if (response.Status < 200 || response.Status >= 300) {
142
+ return { Success: false, Message: `PropFuel list endpoint returned HTTP ${response.Status}.` };
143
+ }
144
+ const files = this.ParseFileListing(response.Body);
145
+ return { Success: true, Message: `Connected to PropFuel account ${auth.AccountID}; ${files.length} data-export file(s) available.` };
232
146
  }
233
147
  catch (err) {
234
148
  const message = err instanceof Error ? err.message : String(err);
235
- return { Success: false, Message: `PropFuel connection failed: ${message}` };
149
+ return { Success: false, Message: `PropFuel connection error: ${message}` };
236
150
  }
237
151
  }
152
+ // ─── Runtime discovery MECHANISM (NOT a baked catalog) ───────────
153
+ /**
154
+ * Discovers objects by listing the live data-export files and deriving the distinct data-type
155
+ * suffix from each `[microtime]-[datatype].json` filename. The returned object set is exactly
156
+ * what the source currently exposes — the frozen contract's `propfuel_data_export_file` is the
157
+ * VERIFICATION FLOOR (discovery must surface at least the feed), never a ceiling.
158
+ */
238
159
  async DiscoverObjects(companyIntegration, contextUser) {
239
- const staticObjects = await super.DiscoverObjects(companyIntegration, contextUser);
240
- if (staticObjects.length > 0)
241
- return staticObjects;
242
- return PROPFUEL_OBJECTS.map(o => ({
243
- Name: o.Name,
244
- Label: o.DisplayName,
245
- Description: o.Description,
160
+ const auth = await this.Authenticate(companyIntegration, contextUser);
161
+ const response = await this.ListFiles(auth);
162
+ this.AssertOK(response, 'list data-export files');
163
+ const files = this.ParseFileListing(response.Body);
164
+ const dataTypes = new Set();
165
+ for (const f of files) {
166
+ const parsed = parseFileName(f);
167
+ if (parsed)
168
+ dataTypes.add(parsed.dataType);
169
+ }
170
+ return [...dataTypes].sort().map(dataType => ({
171
+ Name: dataType,
172
+ Label: dataType,
173
+ Description: `PropFuel data-export stream "${dataType}" (discovered from the data-export file listing).`,
246
174
  SupportsIncrementalSync: true,
247
- SupportsWrite: o.SupportsWrite,
175
+ SupportsWrite: false,
248
176
  }));
249
177
  }
250
178
  /**
251
- * Discovery strategy: fetch one sample record and infer the field set from its JSON
252
- * shape, overlaying any static PK / FK metadata. Falls back to the static field list
253
- * if the live call fails (offline / sandbox with no data / endpoint mismatch).
179
+ * Discovers fields by downloading ONE sample file for the data type and streaming its records
180
+ * through the base data-informed discovery helper. The field set + soft-PK come from the real
181
+ * values the source returns — never a static `STREAM_FIELDS` map. READ-ONLY: the sample file is
182
+ * fetched and parsed, never acked.
254
183
  */
255
184
  async DiscoverFields(companyIntegration, objectName, contextUser) {
256
- try {
257
- const auth = await this.GetAuth(companyIntegration, contextUser);
258
- const path = PROPFUEL_OBJECT_PATHS[objectName];
259
- if (path) {
260
- const url = `${auth.BaseURL}${path}?limit=1`;
261
- const response = await this.MakeHTTPRequest(auth, url, 'GET', this.BuildHeaders(auth));
262
- if (response.Status === 200) {
263
- const records = this.NormalizeResponse(response.Body, null);
264
- if (records.length > 0)
265
- return this.InferFieldsFromSample(records[0], objectName);
266
- }
267
- }
268
- }
269
- catch {
270
- // Live discovery failed — fall through to the static fallback below.
271
- }
272
- const staticFields = await super.DiscoverFields(companyIntegration, objectName, contextUser);
273
- if (staticFields.length > 0)
274
- return staticFields;
275
- const obj = PROPFUEL_OBJECTS.find(o => o.Name.toLowerCase() === objectName.toLowerCase());
276
- if (!obj)
185
+ const auth = await this.Authenticate(companyIntegration, contextUser);
186
+ const response = await this.ListFiles(auth);
187
+ this.AssertOK(response, 'list data-export files');
188
+ const files = this.ParseFileListing(response.Body);
189
+ // Pick the most-recent file (max microtime) of this data type as the discovery sample.
190
+ const sample = files
191
+ .map(f => ({ file: f, parsed: parseFileName(f) }))
192
+ .filter(x => x.parsed && x.parsed.dataType === objectName)
193
+ .sort((a, b) => compareMicrotime(b.parsed.microtime, a.parsed.microtime))[0];
194
+ if (!sample)
277
195
  return [];
278
- return obj.Fields.map(f => {
279
- const fkTarget = this.ResolveForeignKeyTarget(f.Name, f.Description);
280
- return {
281
- Name: f.Name,
282
- Label: f.DisplayName,
283
- Description: f.Description,
284
- DataType: f.Type,
285
- IsRequired: f.IsRequired,
286
- IsUniqueKey: f.IsPrimaryKey,
287
- IsReadOnly: f.IsReadOnly,
288
- IsForeignKey: fkTarget !== null,
289
- ForeignKeyTarget: fkTarget,
290
- };
291
- });
292
- }
293
- InferFieldsFromSample(sample, objectName) {
294
- const staticObj = PROPFUEL_OBJECTS.find(o => o.Name.toLowerCase() === objectName.toLowerCase());
295
- const staticMap = new Map((staticObj?.Fields ?? []).map(f => [f.Name.toLowerCase(), f]));
296
- return Object.entries(sample).map(([key, value]) => {
297
- const overlay = staticMap.get(key.toLowerCase());
298
- const fkTarget = this.ResolveForeignKeyTarget(key, overlay?.Description);
299
- return {
300
- Name: key,
301
- Label: overlay?.DisplayName ?? key,
302
- Description: overlay?.Description ?? '',
303
- DataType: overlay?.Type ?? this.InferType(value),
304
- IsRequired: overlay?.IsRequired ?? false,
305
- IsUniqueKey: overlay?.IsPrimaryKey ?? false,
306
- IsReadOnly: overlay?.IsReadOnly ?? true,
307
- IsForeignKey: fkTarget !== null,
308
- ForeignKeyTarget: fkTarget,
309
- };
310
- });
196
+ const records = await this.DownloadFileRecords(auth, sample.file);
197
+ // Data-informed discovery: full field corpus + statistics-first soft-PK over the real records.
198
+ // Default Pk options let the helper combine uniqueness/null statistics with the built-in
199
+ // naming-convention rank (defaultPkNameRank) to pick a best-available soft key.
200
+ return this.DiscoverFieldsViaStream(records, { ReadOnly: true });
311
201
  }
202
+ // ─── Incremental fetch via the synthetic file-level microtime cursor ──
312
203
  /**
313
- * Resolves a foreign-key target purely from this connector's OWN declared metadata.
204
+ * Fetches records for a data type using the synthetic `__file_microtime` cursor.
314
205
  *
315
- * A field is treated as a FK only when its name ends in `_id` and the `<sibling>`
316
- * stem (singular/plural-aware) matches the external Name of ANOTHER object this
317
- * connector actually declares in PROPFUEL_OBJECTS — e.g. `contact_id` → "Contacts",
318
- * `campaign_id` → "Campaigns". The declared `FK → X` field Description must agree
319
- * with the resolved sibling; if it doesn't, the field is not treated as a FK.
320
- * Returns the sibling object's external Name, or null when nothing is provable.
206
+ * Mechanism: load the stored cursor (max microtime processed), list the live files, select the
207
+ * files of this data type whose microtime exceeds the cursor (ascending microtime order), and
208
+ * download each, emitting every record with full-record pass-through. The max microtime seen
209
+ * across the processed files is persisted as the new cursor — ONLY on full-batch success, so a
210
+ * partial failure leaves the cursor unchanged and the next run resumes from the same point.
321
211
  */
322
- ResolveForeignKeyTarget(fieldName, description) {
323
- const lower = fieldName.toLowerCase();
324
- if (!lower.endsWith('_id') || lower === 'id')
325
- return null;
326
- const stem = lower.slice(0, -'_id'.length); // e.g. "contact", "campaign"
327
- const target = PROPFUEL_OBJECTS.find(o => {
328
- const name = o.Name.toLowerCase();
329
- // singular/plural-aware match against a sibling object's external name
330
- return name === stem || name === `${stem}s` || `${name}s` === stem || name === stem.replace(/y$/, 'ies');
331
- });
332
- if (!target)
333
- return null;
334
- // Honor the declared `FK → X` description when present — it must name this sibling.
335
- if (description && /^fk\s*→/i.test(description.trim())) {
336
- const declaredTarget = description.replace(/^fk\s*→\s*/i, '').trim().toLowerCase();
337
- if (declaredTarget && declaredTarget !== target.Name.toLowerCase())
338
- return null;
339
- }
340
- return target.Name;
341
- }
342
- InferType(value) {
343
- if (value === null || value === undefined)
344
- return 'string';
345
- if (typeof value === 'number')
346
- return Number.isInteger(value) ? 'integer' : 'decimal';
347
- if (typeof value === 'boolean')
348
- return 'boolean';
349
- if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value))
350
- return 'datetime';
351
- return 'string';
352
- }
353
- // ── Fetch + watermark ───────────────────────────────────────────
354
212
  async FetchChanges(ctx) {
355
- const auth = await this.GetAuth(ctx.CompanyIntegration, ctx.ContextUser);
356
- const path = PROPFUEL_OBJECT_PATHS[ctx.ObjectName];
357
- if (!path) {
358
- throw new Error(`PropFuel: no API path configured for object "${ctx.ObjectName}". Update PROPFUEL_OBJECT_PATHS in PropFuelConnector.ts.`);
359
- }
360
- const offset = ctx.CurrentOffset ?? 0;
361
- const url = this.BuildFetchURL(auth.BaseURL, path, offset, ctx.WatermarkValue);
362
- const response = await this.MakeHTTPRequest(auth, url, 'GET', this.BuildHeaders(auth));
363
- if (response.Status < 200 || response.Status >= 300) {
364
- throw new Error(`PropFuel FetchChanges failed for ${ctx.ObjectName}: HTTP ${response.Status}`);
213
+ const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
214
+ const listResponse = await this.ListFiles(auth);
215
+ this.AssertOK(listResponse, 'list data-export files');
216
+ const allFiles = this.ParseFileListing(listResponse.Body);
217
+ // The resume cursor: prefer the engine-provided keyset position, else the watermark value.
218
+ const cursor = ctx.AfterKeyValue ?? ctx.WatermarkValue ?? null;
219
+ const candidates = allFiles
220
+ .map(f => ({ file: f, parsed: parseFileName(f) }))
221
+ .filter((x) => x.parsed != null && x.parsed.dataType === ctx.ObjectName)
222
+ .filter(x => cursor == null || compareMicrotime(x.parsed.microtime, cursor) > 0)
223
+ .sort((a, b) => compareMicrotime(a.parsed.microtime, b.parsed.microtime));
224
+ const records = [];
225
+ let maxMicrotime = cursor ?? '';
226
+ const batchLimit = ctx.BatchSize && ctx.BatchSize > 0 ? ctx.BatchSize : Number.MAX_SAFE_INTEGER;
227
+ let filesProcessed = 0;
228
+ for (const candidate of candidates) {
229
+ if (records.length >= batchLimit)
230
+ break;
231
+ const fileRecords = await this.DownloadFileRecords(auth, candidate.file);
232
+ for (const raw of fileRecords) {
233
+ // Full-record pass-through: the COMPLETE source record reaches ExternalRecord.Fields
234
+ // so the framework's custom-column capture sees every key the source returned.
235
+ records.push({
236
+ ExternalID: this.BuildRecordIdentity(raw, candidate.parsed),
237
+ ObjectType: ctx.ObjectName,
238
+ Fields: raw,
239
+ });
240
+ }
241
+ if (compareMicrotime(candidate.parsed.microtime, maxMicrotime) > 0) {
242
+ maxMicrotime = candidate.parsed.microtime;
243
+ }
244
+ filesProcessed++;
365
245
  }
366
- const records = this.NormalizeResponse(response.Body, null);
367
- const externalRecords = records.map(r => ({
368
- ExternalID: this.ExtractIdFromRecord(r),
369
- ObjectType: ctx.ObjectName,
370
- Fields: r,
371
- }));
372
- return {
373
- Records: externalRecords,
374
- HasMore: records.length >= DEFAULT_PAGE_SIZE,
375
- NextOffset: offset + records.length,
376
- NewWatermarkValue: this.ComputeNewWatermark(externalRecords, ctx.WatermarkValue),
246
+ const moreFiles = filesProcessed < candidates.length;
247
+ const result = {
248
+ Records: records,
249
+ HasMore: moreFiles,
377
250
  };
251
+ if (maxMicrotime && maxMicrotime !== (cursor ?? '')) {
252
+ // Surface the new cursor both as the watermark value and the keyset resume position.
253
+ result.NewWatermarkValue = maxMicrotime;
254
+ result.NextAfterKeyValue = maxMicrotime;
255
+ }
256
+ return result;
257
+ }
258
+ // ─── Helpers ──────────────────────────────────────────────────────
259
+ /** Issues the list call. Path is relative; BuildHeaders + GetBaseURL supply auth + host. */
260
+ async ListFiles(auth) {
261
+ const url = `${this.GetBaseURL({}, auth)}/list`;
262
+ return this.MakeHTTPRequest(auth, url, 'GET', this.BuildHeaders(auth));
378
263
  }
379
- BuildFetchURL(baseURL, path, offset, watermark) {
380
- const params = new URLSearchParams();
381
- params.set('limit', String(DEFAULT_PAGE_SIZE));
382
- params.set('offset', String(offset));
383
- if (watermark)
384
- params.set('updated_since', watermark);
385
- return `${baseURL}${path}?${params.toString()}`;
264
+ /** Downloads one file by name and returns its records (read-only; never acked). */
265
+ async DownloadFileRecords(auth, file) {
266
+ const url = `${this.GetBaseURL({}, auth)}/download/${encodeURIComponent(file)}`;
267
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', this.BuildHeaders(auth));
268
+ this.AssertOK(response, `download file ${file}`);
269
+ return this.NormalizeResponse(response.Body, null);
386
270
  }
387
- ComputeNewWatermark(records, current) {
388
- let latest;
389
- for (const record of records) {
390
- for (const key of WATERMARK_CANDIDATE_FIELDS) {
391
- const value = record.Fields[key];
392
- if (typeof value === 'string' && (!latest || value > latest))
393
- latest = value;
271
+ /**
272
+ * Parses the list endpoint body into an array of file names. The listing may be a bare array of
273
+ * strings, an array of objects with a name/file/filename key, or an object wrapping such an array.
274
+ */
275
+ ParseFileListing(body) {
276
+ const arr = Array.isArray(body)
277
+ ? body
278
+ : (isRecord(body) ? this.FindArrayInObject(body) : []);
279
+ const files = [];
280
+ for (const item of arr) {
281
+ if (typeof item === 'string') {
282
+ files.push(item);
283
+ }
284
+ else if (isRecord(item)) {
285
+ const name = item['name'] ?? item['file'] ?? item['filename'] ?? item['fileName'] ?? item['key'];
286
+ if (typeof name === 'string')
287
+ files.push(name);
394
288
  }
395
289
  }
396
- if (!latest)
397
- return current ?? undefined;
398
- if (current && latest <= current)
399
- return current;
400
- return latest;
401
- }
402
- // ── Default config + field mappings ─────────────────────────────
403
- GetDefaultConfiguration() {
404
- return { DefaultSchemaName: 'PropFuel', DefaultObjects: [] };
290
+ return files;
405
291
  }
406
- GetDefaultFieldMappings(objectName, _entityName) {
407
- const obj = PROPFUEL_OBJECTS.find(o => o.Name.toLowerCase() === objectName.toLowerCase());
408
- if (!obj)
409
- return [];
410
- return obj.Fields.map(f => ({
411
- SourceFieldName: f.Name,
412
- DestinationFieldName: f.Name === 'id' ? 'ExternalID' : f.Name,
413
- IsKeyField: f.IsPrimaryKey,
414
- }));
292
+ /** Finds the first array-valued property of an object (the file listing under a wrapper key). */
293
+ FindArrayInObject(obj) {
294
+ for (const v of Object.values(obj)) {
295
+ if (Array.isArray(v))
296
+ return v;
297
+ }
298
+ return [];
415
299
  }
416
- // ─────────────────────────────────────────────────────────────────
417
- // Private helpers
418
- // ─────────────────────────────────────────────────────────────────
419
- async GetAuth(source, contextUser, forceRefresh = false) {
420
- if (!forceRefresh && this.authState)
421
- return this.authState;
422
- const config = this.IsConfig(source)
423
- ? source
424
- : await this.ParseConfig(source, contextUser);
425
- const baseURL = this.StripTrailingSlash(config.BaseURL);
426
- this.authState = {
427
- Token: config.ApiKey,
428
- Config: { ...config, BaseURL: baseURL },
429
- BaseURL: baseURL,
430
- };
431
- return this.authState;
300
+ /**
301
+ * Builds a stable record identity. The per-record schema is undocumented (no provable PK), so
302
+ * the connector prefers a common id key when one is present and otherwise falls back to a
303
+ * deterministic content hash scoped to the file's microtime — the base engine dedupes by the
304
+ * record-map + content hash regardless.
305
+ */
306
+ BuildRecordIdentity(raw, parsed) {
307
+ for (const k of ['id', 'ID', 'Id', 'uuid', 'externalID', 'ExternalID']) {
308
+ const v = raw[k];
309
+ if (typeof v === 'string' && v.length > 0)
310
+ return v;
311
+ if (typeof v === 'number')
312
+ return String(v);
313
+ }
314
+ return `${parsed.microtime}:${stableHash(raw)}`;
432
315
  }
433
- IsConfig(source) {
434
- return source.BaseURL !== undefined
435
- && source.ApiKey !== undefined;
316
+ /** Throws a descriptive error on a non-2xx response (404 included, since a missing file is unexpected here). */
317
+ AssertOK(response, action) {
318
+ if (response.Status < 200 || response.Status >= 300) {
319
+ throw new Error(`PropFuel failed to ${action}: HTTP ${response.Status}`);
320
+ }
436
321
  }
437
- async ParseConfig(companyIntegration, contextUser) {
438
- if (companyIntegration.CredentialID) {
439
- const fromCred = await this.ParseConfigFromCredential(companyIntegration.CredentialID, contextUser);
440
- if (fromCred)
441
- return fromCred;
322
+ /**
323
+ * Resolves the bearer Token + per-tenant AccountID from the linked Credential entity, falling
324
+ * back to the CompanyIntegration.Configuration JSON. AccountID is per-tenant config — NEVER hardcoded.
325
+ */
326
+ async LoadCredentials(companyIntegration, contextUser) {
327
+ let token;
328
+ let accountID;
329
+ let baseHost;
330
+ const credentialID = companyIntegration.CredentialID;
331
+ if (credentialID) {
332
+ const fromCred = await this.LoadFromCredentialEntity(credentialID, contextUser);
333
+ if (fromCred) {
334
+ token = fromCred.Token ?? token;
335
+ accountID = fromCred.AccountID ?? accountID;
336
+ }
442
337
  }
443
- if (companyIntegration.Configuration) {
444
- const parsed = JSON.parse(companyIntegration.Configuration);
445
- return this.ExtractConfig(parsed);
338
+ // Configuration JSON supplies AccountID (and a token fallback) when not on the credential.
339
+ const configJson = companyIntegration.Configuration;
340
+ if (configJson) {
341
+ const fromConfig = this.ParseCredentialJson(configJson);
342
+ if (fromConfig) {
343
+ token = token ?? fromConfig.Token;
344
+ accountID = accountID ?? fromConfig.AccountID;
345
+ baseHost = baseHost ?? fromConfig.BaseHost;
346
+ }
347
+ }
348
+ if (!token) {
349
+ throw new Error('No PropFuel credential or Configuration JSON found — a "Token" is required.');
446
350
  }
447
- throw new Error('PropFuel connector requires either CredentialID or Configuration JSON');
351
+ if (!accountID) {
352
+ throw new Error('No PropFuel AccountID found — set "AccountID" on the credential or Configuration JSON (it is per-tenant, never hardcoded).');
353
+ }
354
+ return { Token: token, AccountID: accountID, BaseHost: baseHost };
448
355
  }
449
- async ParseConfigFromCredential(credentialID, contextUser, provider) {
356
+ /** Loads Token/AccountID from a Credential entity's Values JSON. */
357
+ async LoadFromCredentialEntity(credentialID, contextUser, provider) {
450
358
  const md = provider ?? new Metadata();
451
359
  const credential = await md.GetEntityObject('MJ: Credentials', contextUser);
452
360
  const loaded = await credential.Load(credentialID);
453
361
  if (!loaded || !credential.Values)
454
362
  return null;
455
- const values = JSON.parse(credential.Values);
456
- return this.ExtractConfig(values);
363
+ return this.ParseCredentialJson(credential.Values);
457
364
  }
458
- ExtractConfig(values) {
459
- const get = (...keys) => {
460
- for (const key of keys) {
461
- const hit = Object.entries(values).find(([k]) => k.toLowerCase() === key.toLowerCase());
462
- if (hit)
463
- return String(hit[1] ?? '');
464
- }
465
- return undefined;
466
- };
467
- // Accepts vendor-specific PascalCase names AND the field names from the
468
- // standard 'API Key with Endpoint' credential schema (endpoint, apiKey)
469
- // so the metadata can reference the standard schema rather than a
470
- // PropFuel-specific one.
471
- const baseURL = get('BaseURL', 'BaseUrl', 'base_url', 'endpoint');
472
- const apiKey = get('ApiKey', 'apiKey', 'api_key');
473
- if (!baseURL)
474
- throw new Error('PropFuel configuration missing required field: BaseURL');
475
- if (!apiKey)
476
- throw new Error('PropFuel configuration missing required field: ApiKey');
477
- return { BaseURL: this.StripTrailingSlash(baseURL), ApiKey: apiKey };
478
- }
479
- // ── HTTP helpers ────────────────────────────────────────────────
480
- async ExecuteFetch(url, method, headers, body, timeoutMs) {
481
- const controller = new AbortController();
482
- const timer = setTimeout(() => controller.abort(), timeoutMs);
365
+ /** Parses a JSON string to extract Token + AccountID (tolerant of casing/aliases). */
366
+ ParseCredentialJson(json) {
483
367
  try {
484
- const requestInit = { method, headers, signal: controller.signal };
485
- if (body !== undefined && method !== 'GET' && method !== 'DELETE') {
486
- requestInit.body = JSON.stringify(body);
487
- }
488
- return await fetch(url, requestInit);
489
- }
490
- finally {
491
- clearTimeout(timer);
492
- }
493
- }
494
- async BuildRESTResponse(response) {
495
- const headers = {};
496
- response.headers.forEach((v, k) => { headers[k.toLowerCase()] = v; });
497
- const contentType = headers['content-type'] ?? '';
498
- const body = contentType.includes('application/json')
499
- ? await this.ParseJsonSafely(response)
500
- : await response.text();
501
- return { Status: response.status, Body: body, Headers: headers };
502
- }
503
- async ParseJsonSafely(response) {
504
- try {
505
- return await response.json();
368
+ const result = PropFuelCredentialSchema.safeParse(JSON.parse(json));
369
+ if (!result.success)
370
+ return null;
371
+ const p = result.data;
372
+ const token = p.Token ?? p.token ?? p.apiKey ?? p.ApiKey;
373
+ const accountID = p.AccountID ?? p.accountId ?? p.accountID;
374
+ if (!token && accountID == null)
375
+ return null;
376
+ const baseHost = p.apiBaseUrl ?? p.BaseURL;
377
+ return { Token: token ?? '', AccountID: accountID != null ? String(accountID) : '', BaseHost: baseHost };
506
378
  }
507
379
  catch {
508
380
  return null;
509
381
  }
510
382
  }
511
- ShouldRetry(status) {
512
- return status === 429 || status === 502 || status === 503 || status === 504;
513
- }
514
- ComputeRetryDelay(response, attempt) {
515
- const retryAfter = response.headers.get('retry-after');
516
- if (retryAfter) {
517
- const seconds = Number.parseInt(retryAfter, 10);
518
- if (!Number.isNaN(seconds) && seconds > 0)
519
- return seconds * 1000;
520
- }
521
- return Math.min(Math.pow(2, attempt) * 1000, 15_000);
522
- }
523
- async Throttle(minIntervalMs) {
524
- const elapsed = Date.now() - this.lastRequestTime;
525
- if (elapsed < minIntervalMs)
526
- await this.Sleep(minIntervalMs - elapsed);
527
- }
528
- Sleep(ms) {
529
- return new Promise(resolve => setTimeout(resolve, ms));
530
- }
531
- // ── Misc helpers ────────────────────────────────────────────────
532
- ExtractIdFromRecord(body) {
533
- const candidates = ['id', 'ID', 'Id', 'contact_id', 'campaign_id', 'conversation_id', 'capture_id'];
534
- for (const key of candidates) {
535
- const value = body[key];
536
- if (value !== undefined && value !== null)
537
- return String(value);
538
- }
539
- return '';
540
- }
541
- StripTrailingSlash(url) {
542
- return url.endsWith('/') ? url.slice(0, -1) : url;
543
- }
544
383
  };
545
384
  PropFuelConnector = __decorate([
546
385
  RegisterClass(BaseIntegrationConnector, 'PropFuelConnector')
547
386
  ], PropFuelConnector);
548
387
  export { PropFuelConnector };
549
- /** Tree-shaking prevention — import and call from module entry point. */
550
- export function LoadPropFuelConnector() { }
388
+ // ─── Module-level constants + helpers (mechanism, NOT a catalog) ──────
389
+ /** PropFuel host root. Tenant + endpoint are appended at runtime. */
390
+ const PROPFUEL_HOST = 'https://app.propfuel.com';
391
+ /** Stable ordering key name for keyset/no-watermark resume — the filename microtime prefix. */
392
+ const MICROTIME_ORDERING_KEY = 'microtime';
393
+ /** Zod schema for the credential/Configuration JSON shape (tolerant of casing aliases). */
394
+ const PropFuelCredentialSchema = z.object({
395
+ Token: z.string().optional(),
396
+ token: z.string().optional(),
397
+ apiKey: z.string().optional(),
398
+ ApiKey: z.string().optional(),
399
+ AccountID: z.union([z.string(), z.number()]).optional(),
400
+ accountId: z.union([z.string(), z.number()]).optional(),
401
+ accountID: z.union([z.string(), z.number()]).optional(),
402
+ apiBaseUrl: z.string().optional(),
403
+ BaseURL: z.string().optional(),
404
+ }).passthrough();
405
+ /** Narrows an unknown value to a plain record. */
406
+ function isRecord(v) {
407
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
408
+ }
409
+ /**
410
+ * Parses a PropFuel data-export filename of the form `[microtime]-[datatype].json`.
411
+ * The microtime is the leading numeric segment (PHP microtime, e.g. `1717804800.123456`); the data
412
+ * type is everything between the first `-` and the `.json` suffix. Returns null when the name does
413
+ * not match the convention.
414
+ */
415
+ export function parseFileName(fileName) {
416
+ // Strip any path prefix and the .json extension.
417
+ const base = fileName.split('/').pop() ?? fileName;
418
+ const noExt = base.replace(/\.json$/i, '');
419
+ const dashIndex = noExt.indexOf('-');
420
+ if (dashIndex <= 0)
421
+ return null;
422
+ const microtime = noExt.slice(0, dashIndex);
423
+ const dataType = noExt.slice(dashIndex + 1);
424
+ if (!/^[0-9]+(\.[0-9]+)?$/.test(microtime) || dataType.length === 0)
425
+ return null;
426
+ return { microtime, dataType };
427
+ }
428
+ /**
429
+ * Compares two microtime strings chronologically. Microtimes are numeric (possibly fractional);
430
+ * numeric comparison avoids lexical pitfalls (e.g. different integer widths). Returns >0 when a>b.
431
+ */
432
+ export function compareMicrotime(a, b) {
433
+ const na = Number(a);
434
+ const nb = Number(b);
435
+ if (Number.isFinite(na) && Number.isFinite(nb)) {
436
+ return na === nb ? 0 : (na > nb ? 1 : -1);
437
+ }
438
+ return a === b ? 0 : (a > b ? 1 : -1);
439
+ }
440
+ /** Small deterministic hash for record identity fallback (FNV-1a, hex). */
441
+ function stableHash(record) {
442
+ const json = JSON.stringify(record, Object.keys(record).sort());
443
+ let h = 0x811c9dc5;
444
+ for (let i = 0; i < json.length; i++) {
445
+ h ^= json.charCodeAt(i);
446
+ h = Math.imul(h, 0x01000193);
447
+ }
448
+ return (h >>> 0).toString(16);
449
+ }
551
450
  //# sourceMappingURL=PropFuelConnector.js.map