@memberjunction/connector-neon-crm 1.0.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.
@@ -0,0 +1,1039 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
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
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ /**
8
+ * NeonCRMConnector — Integration connector for the Neon CRM (Neon One) REST API v2.
9
+ *
10
+ * API docs / spec:
11
+ * - https://developer.neoncrm.com/api-v2/ (developer portal)
12
+ * - OAS3 spec v2.11 (credential-free — the catalog source-of-record; objects/fields
13
+ * are seeded as Declared metadata, NOT baked into this connector)
14
+ *
15
+ * ── Auth: HTTP Basic (org id + API key) ─────────────────────────────────────
16
+ * `Authorization: Basic base64(orgId:apiKey)` per RFC 7617. The header is built via
17
+ * the shared {@link buildBasicAuthHeaderValue} auth-helper — base64/crypto is NEVER
18
+ * inlined here (connector-code-conventions "no inline crypto" rule). Neon CRM's
19
+ * developer docs explicitly state OAuth2 is for constituent (end-user) auth only and
20
+ * is NOT supported for system-user API access, so Basic is the only wired path.
21
+ *
22
+ * ── API versioning ──────────────────────────────────────────────────────────
23
+ * Every request carries the `NEON-API-VERSION` header (default `2.11`, overridable via
24
+ * the credential / Configuration). Omitting it defaults to latest server-side; an
25
+ * invalid/deprecated version yields a 4XX.
26
+ *
27
+ * ── Base URL ────────────────────────────────────────────────────────────────
28
+ * `https://api.neoncrm.com/v2` (the integration's NavigationBaseURL), overridable via
29
+ * the credential's BaseURL for sandbox/trial pods.
30
+ *
31
+ * ── Catalog (metadata-driven, NOT hardcoded) ───────────────────────────────
32
+ * Objects/fields come from the Declared metadata in
33
+ * `metadata/integrations/neon-crm/.neon-crm.integration.json` (seeded from the
34
+ * credential-free OAS3 spec) and loaded into the IntegrationEngineBase cache. There is
35
+ * NO module-level object/field catalog in this connector — discovery reads the full
36
+ * universe straight from the cache (the base-class default).
37
+ *
38
+ * ── Pagination ──────────────────────────────────────────────────────────────
39
+ * Neon uses page-number pagination: `currentPage` (0-based) + `pageSize` (max 200) query
40
+ * params, with a `pagination` envelope ({ currentPage, pageSize, totalPages, totalResults })
41
+ * on list/search responses. {@link ExtractPaginationInfo} reads `pagination.totalPages`.
42
+ *
43
+ * ── Incremental ─────────────────────────────────────────────────────────────
44
+ * Metadata-driven. An IO with `SupportsIncrementalSync=true` carries
45
+ * `IncrementalWatermarkField='timestamps.lastModifiedDateTime'`; watermark advancement
46
+ * reads the latest lastModifiedDateTime from the fetched batch on the final batch only
47
+ * (partial-failure-safe).
48
+ *
49
+ * SERVER-SIDE narrowing (matrix C1): for a POST-search door (Activity, Donation, Order, …)
50
+ * whose door has a documented last-modified SEARCH FIELD, the incremental pass appends a
51
+ * `{ field, operator:'GREATER_AND_EQUAL', value:<yyyy-MM-dd watermark> }` criterion to the
52
+ * SearchRequest.searchFields (the OAS3 SearchRequest shape) so the API returns ONLY changed
53
+ * records. The search-field name is resolved from `Configuration.WatermarkSearchField` (per-
54
+ * connection override) or the connector's DOOR_WATERMARK_SEARCH_FIELD map of Neon-documented
55
+ * standard fields; on first sync (no watermark) NO criterion is sent (full pull). Doors with no
56
+ * documented date search field — and ALL GET-list / nested-descent objects (a door-level date
57
+ * filter would narrow by the PARENT's date, not the leaf's, and is lossy) — keep content-hash
58
+ * narrowing, which is the correct, lossless fallback (just not server-narrowed).
59
+ *
60
+ * ── Write ───────────────────────────────────────────────────────────────────
61
+ * Full CRUD for the writable objects (Accounts, Donations, Events, EventRegistrations,
62
+ * Memberships, Activities, Grants, Campaigns, Pledges, Webhooks, …) is metadata-driven
63
+ * through the base BaseRESTIntegrationConnector generic per-operation CRUD path. The ONE
64
+ * override is CreateRecord for donation/payment-class objects: a 2xx-but-no-id response or
65
+ * a write timeout must NOT be blindly retried — reconcile-before-retry (see the override).
66
+ */
67
+ import { RegisterClass } from '@memberjunction/global';
68
+ import { Metadata } from '@memberjunction/core';
69
+ import { BaseIntegrationConnector, BaseRESTIntegrationConnector, buildBasicAuthHeaderValue, computeContentHash, serializeKeyValue, } from '@memberjunction/integration-engine';
70
+ import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
71
+ // ─── Constants ───────────────────────────────────────────────────────
72
+ /** The canonical MJ: Integrations.Name — part of the three-way invariant. */
73
+ const INTEGRATION_NAME = 'Neon CRM';
74
+ /** The Account IntegrationObject name (its accountId PK is nested — see TransformRecord, DEFECT 1). */
75
+ const ACCOUNT_OBJECT_NAME = 'Account';
76
+ /** The scalar account-id field name (top-level PK; nested inside individual/companyAccount on the wire). */
77
+ const ACCOUNT_ID_FIELD = 'accountId';
78
+ /** Default API base URL (the integration NavigationBaseURL); overridable per-credential. */
79
+ const DEFAULT_BASE_URL = 'https://api.neoncrm.com/v2';
80
+ /** Default Neon API version sent in the NEON-API-VERSION header. */
81
+ const DEFAULT_API_VERSION = '2.11';
82
+ /** Header Neon uses for API versioning. */
83
+ const API_VERSION_HEADER = 'NEON-API-VERSION';
84
+ /** Neon page-number pagination params. currentPage is 0-based. */
85
+ const PAGE_PARAM = 'currentPage';
86
+ const PAGE_SIZE_PARAM = 'pageSize';
87
+ /** Neon caps a page at 200 rows regardless of a larger requested pageSize. */
88
+ const NEON_MAX_PAGE_SIZE = 200;
89
+ const DEFAULT_PAGE_SIZE = 200;
90
+ const DEFAULT_MAX_RETRIES = 3;
91
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
92
+ /**
93
+ * Object-name patterns whose writes are financial / non-idempotent (donations, payments,
94
+ * pledges, orders, recurring donations). A 2xx-without-id response or a timeout on these
95
+ * MUST be reconciled before any retry — see {@link CreateRecord}.
96
+ */
97
+ const FINANCIAL_WRITE_PATTERN = /donation|payment|pledge|order|installment|recurring/i;
98
+ // ─── Enumeration Configuration (metadata-driven) ─────────────────────
99
+ //
100
+ // The corrected Declared metadata carries, per active IntegrationObject, a `Configuration` JSON
101
+ // describing HOW to ENUMERATE that object. This connector READS it (never bakes it). Three modes:
102
+ // 1. Direct collection, GET — ListMethod="GET", nesting="(direct collection)".
103
+ // 2. Direct collection, POST — ListMethod="POST" + ListBody (a search request body).
104
+ // 3. Access-path descent — nesting is a chain (e.g. "Account -> pledges[] -> pledgePayments[]"):
105
+ // list the DOOR (its listMethod), then descend the chain IN MEMORY.
106
+ /** Sentinel `nesting` value meaning "the door collection IS this object's records" (no descent). */
107
+ const DIRECT_COLLECTION = '(direct collection)';
108
+ /** The Neon search pagination envelope key that page-number is injected into for POST listing. */
109
+ const SEARCH_PAGINATION_KEY = 'pagination';
110
+ /** The Neon SearchRequest array key carrying the filter criteria (OAS3 SearchRequest.searchFields). */
111
+ const SEARCH_FIELDS_KEY = 'searchFields';
112
+ /**
113
+ * Neon's documented "greater-than-or-equal" search operator (OAS3 SearchCriteria.operator enum).
114
+ * A `>=` criterion on the door's last-modified search field is how we server-narrow an incremental
115
+ * pull to records changed at/after the watermark.
116
+ */
117
+ const SEARCH_OP_GREATER_AND_EQUAL = 'GREATER_AND_EQUAL';
118
+ /**
119
+ * Documented standard last-modified SEARCH FIELD name per POST-search door, used to build the
120
+ * incremental `GREATER_AND_EQUAL` criterion. These are the Neon-documented standard search-field
121
+ * DISPLAY names (the value the OAS3 `SearchCriteria.field` expects) for each door's
122
+ * `/search/searchFields` catalog — NOT the response field (`timestamps.lastModifiedDateTime`).
123
+ * Provable-only: a door appears here ONLY when Neon documents a last-modified standard search field
124
+ * for it. Doors absent from this map (and GET-list objects) get NO server-side filter — content-hash
125
+ * narrowing remains the correct, lossless fallback. A per-connection metadata override
126
+ * (`Configuration.WatermarkSearchField`) takes precedence over this map.
127
+ */
128
+ const DOOR_WATERMARK_SEARCH_FIELD = {
129
+ '/accounts/search': 'Account Last Modified Date/Time',
130
+ };
131
+ // ─── Connector Implementation ────────────────────────────────────────
132
+ let NeonCRMConnector = class NeonCRMConnector extends BaseRESTIntegrationConnector {
133
+ constructor() {
134
+ super(...arguments);
135
+ /** Cached auth context for the current sync run. */
136
+ this.authCache = null;
137
+ }
138
+ // Returns the EXACT MJ: Integrations.Name string LITERAL so the T1 ThreeWayName
139
+ // invariant can statically parse the getter's returned value from connector source.
140
+ get IntegrationName() { return 'Neon CRM'; }
141
+ // ── Capability getters: METADATA-DRIVEN (no hardcoded answer) ─────
142
+ //
143
+ // Write capability FOLLOWS the per-operation CRUD columns on the cached IntegrationObjects
144
+ // (Declared metadata). An object is create-capable when it declares both CreateAPIPath +
145
+ // CreateMethod; same for update/delete. With no write metadata authored the surface is
146
+ // read-only (false). The base generic CRUD path executes the populated columns.
147
+ get SupportsCreate() {
148
+ return this.anyObjectDeclares(o => !!o.CreateAPIPath && !!o.CreateMethod);
149
+ }
150
+ get SupportsUpdate() {
151
+ return this.anyObjectDeclares(o => !!o.UpdateAPIPath && !!o.UpdateMethod);
152
+ }
153
+ get SupportsDelete() {
154
+ return this.anyObjectDeclares(o => !!o.DeleteAPIPath && !!o.DeleteMethod);
155
+ }
156
+ /** True when any cached IntegrationObject satisfies the predicate. []→false when the
157
+ * engine cache is unavailable (capability probed before configuration) — fail-safe read-only. */
158
+ anyObjectDeclares(pred) {
159
+ const integration = IntegrationEngineBase.Instance.GetIntegrationByName(INTEGRATION_NAME);
160
+ if (!integration)
161
+ return false;
162
+ return IntegrationEngineBase.Instance.GetActiveIntegrationObjects(integration.ID).some(pred);
163
+ }
164
+ // ── Sync-efficiency hooks (evidence-backed) ──────────────────────
165
+ //
166
+ // Neon's documented per-app rate limit is conservative; the integration BatchMaxRequestCount=5
167
+ // reflects the documented per-window request budget. A modest token bucket keeps a large
168
+ // 119-object sync inside the vendor's window. Retry-After is parsed in MakeHTTPRequest.
169
+ get RateLimitPolicy() {
170
+ return { TokensPerSec: 5, Burst: 5, ThrottleBackoffFactor: 0.5 };
171
+ }
172
+ /** Parse Neon's Retry-After (seconds or http-date) into ms for the engine's AIMD bucket. */
173
+ ExtractRetryAfterMs(error) {
174
+ if (!error || typeof error !== 'object')
175
+ return undefined;
176
+ const e = error;
177
+ if (typeof e.RetryAfterMs === 'number')
178
+ return e.RetryAfterMs;
179
+ if (typeof e.retryAfterMs === 'number')
180
+ return e.retryAfterMs;
181
+ return undefined;
182
+ }
183
+ // ── Per-record transform: nested-account-id flattening (DEFECT 1) ─
184
+ //
185
+ // Neon's GET /accounts returns each account as `{ individualAccount: { accountId, ... } }`
186
+ // OR `{ companyAccount: { accountId, ... } }` — the scalar `accountId` that is the Account PK
187
+ // lives ONE LEVEL DOWN. Without lifting it to the top level the connector's PK detection finds
188
+ // nothing → content-hash identity → drift / duplicate rows on re-sync. We lift the nested id to
189
+ // a TOP-LEVEL `accountId` for the Account object (and the per-id detail GET which is also an
190
+ // Account record). FULL-RECORD PASS-THROUGH is preserved: we ONLY ADD `accountId`; the nested
191
+ // `individualAccount` / `companyAccount` blobs are never dropped. The metadata declares
192
+ // Account.accountId as the PK separately — this hook just makes the field exist at top level.
193
+ TransformRecord(raw, obj, _fields) {
194
+ if (obj.Name === ACCOUNT_OBJECT_NAME) {
195
+ return this.liftNestedAccountId(raw);
196
+ }
197
+ return raw;
198
+ }
199
+ /**
200
+ * Lifts a nested `individualAccount.accountId` / `companyAccount.accountId` to a top-level
201
+ * `accountId` WITHOUT overwriting an existing top-level value and WITHOUT dropping the nested
202
+ * blobs (full-record pass-through). Returns the input unchanged when there is nothing to lift.
203
+ * Reused by {@link TransformRecord} (Account direct sync) and by the access-path descent's
204
+ * door-PK resolution (so a Consent leaf can be stamped with its account's id — DEFECT 2).
205
+ */
206
+ liftNestedAccountId(raw) {
207
+ const existing = raw[ACCOUNT_ID_FIELD];
208
+ if (existing != null && serializeKeyValue(existing).length > 0) {
209
+ return raw; // already top-level — nothing to lift
210
+ }
211
+ const nested = this.readNestedAccountId(raw.individualAccount) ??
212
+ this.readNestedAccountId(raw.companyAccount);
213
+ if (nested == null)
214
+ return raw;
215
+ return { ...raw, [ACCOUNT_ID_FIELD]: nested };
216
+ }
217
+ /** Reads a scalar `accountId` from a nested individual/company account blob, if present. */
218
+ readNestedAccountId(blob) {
219
+ if (!blob || typeof blob !== 'object' || Array.isArray(blob))
220
+ return undefined;
221
+ const v = blob[ACCOUNT_ID_FIELD];
222
+ return typeof v === 'string' || typeof v === 'number' ? v : undefined;
223
+ }
224
+ // ── Write override: reconcile-before-retry for financial objects ──
225
+ //
226
+ // GENUINELY IDIOSYNCRATIC. Neon donation/payment/pledge/order/recurring writes are
227
+ // FINANCIAL and non-idempotent: a network timeout or a 2xx-without-id response after a
228
+ // POST may mean the charge/record was actually created server-side. Blindly retrying
229
+ // (the generic path's caller might) risks a DOUBLE donation/charge. So for these objects
230
+ // we mark the result so the engine does NOT auto-retry on timeout — the operator must
231
+ // reconcile by external id / account / amount / timestamp first (per the source test plan's
232
+ // critical warning). Non-financial creates ride the base generic path unchanged.
233
+ async CreateRecord(ctx) {
234
+ const ci = ctx.CompanyIntegration;
235
+ const isFinancial = FINANCIAL_WRITE_PATTERN.test(ctx.ObjectName);
236
+ try {
237
+ // Delegate the actual request construction to the base generic per-operation CRUD path
238
+ // (reads CreateAPIPath/Method/BodyShape/BodyKey/IDLocation; routes through BuildCreatedResult,
239
+ // which already fails LOUDLY on a 2xx-without-id — exactly the no-silent-duplicate guard
240
+ // a financial write needs).
241
+ return await super.CreateRecord(ctx);
242
+ }
243
+ catch (err) {
244
+ const message = err instanceof Error ? err.message : String(err);
245
+ if (isFinancial && this.isTimeoutError(err)) {
246
+ // Do NOT signal a transient/retryable error to the engine — a financial write whose
247
+ // response we never saw may have SUCCEEDED server-side. Surface a non-retryable failure
248
+ // instructing reconcile-before-retry.
249
+ return {
250
+ Success: false,
251
+ StatusCode: 0,
252
+ ErrorMessage: `Neon CRM ${ctx.ObjectName} create did not return a confirmed response (timeout). ` +
253
+ `RECONCILE BEFORE RETRY — the record may already exist server-side. ` +
254
+ `Reconcile by external id / account / amount / timestamp / returned transaction id ` +
255
+ `before re-issuing this write. Underlying: ${message}`,
256
+ };
257
+ }
258
+ throw err;
259
+ }
260
+ }
261
+ /** Whether an error is a request timeout / aborted fetch (distinct from a clean non-2xx response). */
262
+ isTimeoutError(err) {
263
+ if (!(err instanceof Error))
264
+ return false;
265
+ const msg = err.message.toLowerCase();
266
+ return msg.includes('timeout') || msg.includes('abort') || msg.includes('timed out');
267
+ }
268
+ // ─── BaseRESTIntegrationConnector abstract methods ──────────────
269
+ /**
270
+ * HTTP Basic authentication. Builds the `Basic base64(orgId:apiKey)` header via the
271
+ * shared auth-helper (no inline base64). Credential bytes are resolved at runtime.
272
+ */
273
+ async Authenticate(companyIntegration, contextUser) {
274
+ if (this.authCache)
275
+ return this.authCache;
276
+ const config = await this.ParseConfig(companyIntegration, contextUser);
277
+ const authorizationHeader = buildBasicAuthHeaderValue({
278
+ Username: config.OrgID,
279
+ Password: config.APIKey,
280
+ });
281
+ const auth = {
282
+ AuthorizationHeader: authorizationHeader,
283
+ BaseUrl: config.BaseURL,
284
+ Config: config,
285
+ };
286
+ this.authCache = auth;
287
+ return auth;
288
+ }
289
+ /** Sends the Basic auth header + NEON-API-VERSION + JSON content negotiation on every request. */
290
+ BuildHeaders(auth) {
291
+ const neon = auth;
292
+ return {
293
+ 'Authorization': neon.AuthorizationHeader,
294
+ [API_VERSION_HEADER]: neon.Config?.APIVersion ?? DEFAULT_API_VERSION,
295
+ 'Accept': 'application/json',
296
+ 'Content-Type': 'application/json',
297
+ };
298
+ }
299
+ GetBaseURL(_companyIntegration, auth) {
300
+ return auth.BaseUrl;
301
+ }
302
+ /**
303
+ * Normalizes Neon CRM responses. Handles the real shapes:
304
+ * 1. List/search envelope `{ <dataKey>: [...], pagination: {...} }` — the dataKey is the
305
+ * resource collection (e.g. `accounts`, `searchResults`, `donations`, `memberships`).
306
+ * When ResponseDataKey is set on the IO we read it; otherwise we auto-detect the first
307
+ * array-valued property that is NOT the `pagination` envelope.
308
+ * 2. Raw array at the root.
309
+ * 3. Single object — per-id GET endpoints return ONE record (often itself wrapped, e.g.
310
+ * `{ accountId, individualAccount: {...} }`); kept as a single-element list.
311
+ *
312
+ * The FULL source record passes through (no field filtering) so the framework's custom-column
313
+ * capture sees everything Neon returned.
314
+ */
315
+ NormalizeResponse(rawBody, responseDataKey) {
316
+ if (rawBody == null)
317
+ return [];
318
+ if (Array.isArray(rawBody)) {
319
+ return rawBody;
320
+ }
321
+ if (typeof rawBody === 'object') {
322
+ const body = rawBody;
323
+ // Explicit data key wins when authored.
324
+ if (responseDataKey && Array.isArray(body[responseDataKey])) {
325
+ return body[responseDataKey];
326
+ }
327
+ // Auto-detect the collection array in a Neon list/search envelope.
328
+ const collection = this.findCollectionArray(body);
329
+ if (collection)
330
+ return collection;
331
+ // Single-object detail record (per-id GET). Keep it.
332
+ return [body];
333
+ }
334
+ return [];
335
+ }
336
+ /**
337
+ * Finds the records array inside a Neon list/search envelope. Neon names the collection
338
+ * after the resource (`accounts`, `donations`, `memberships`, `searchResults`, …) and always
339
+ * carries a sibling `pagination` object. We pick the first array-valued property that is not
340
+ * `pagination`. Returns null when no collection array is present (a single-object response).
341
+ */
342
+ findCollectionArray(body) {
343
+ for (const [key, value] of Object.entries(body)) {
344
+ if (key === 'pagination')
345
+ continue;
346
+ if (Array.isArray(value))
347
+ return value;
348
+ }
349
+ return null;
350
+ }
351
+ /**
352
+ * Derives Neon page-number pagination state. Neon returns a `pagination` envelope with
353
+ * `currentPage` (0-based), `totalPages`, `totalResults`. More pages remain while
354
+ * currentPage + 1 < totalPages. Falls back to an empty-page terminator when no envelope.
355
+ */
356
+ ExtractPaginationInfo(rawBody, _paginationType, currentPage, currentOffset, _pageSize) {
357
+ if (!rawBody || typeof rawBody !== 'object') {
358
+ return { HasMore: false };
359
+ }
360
+ const body = rawBody;
361
+ const pagination = body.pagination;
362
+ if (pagination && typeof pagination.totalPages === 'number') {
363
+ // Neon's currentPage is 0-based. Prefer the envelope's value over our request counter.
364
+ const serverPage = typeof pagination.currentPage === 'number' ? pagination.currentPage : currentPage;
365
+ const total = typeof pagination.totalResults === 'number' ? pagination.totalResults : undefined;
366
+ const hasMore = serverPage + 1 < pagination.totalPages;
367
+ return hasMore
368
+ ? { HasMore: true, NextPage: serverPage + 1, TotalRecords: total }
369
+ : { HasMore: false, TotalRecords: total };
370
+ }
371
+ // No pagination envelope: terminate on an empty collection (avoids infinite loops).
372
+ const collection = this.findCollectionArray(body);
373
+ if (!collection || collection.length === 0) {
374
+ return { HasMore: false };
375
+ }
376
+ return { HasMore: true, NextPage: currentPage + 1, NextOffset: currentOffset + collection.length };
377
+ }
378
+ /**
379
+ * Emits Neon page-number pagination params: `currentPage` (0-based) + `pageSize` (capped at
380
+ * the vendor's 200 ceiling). currentPage is page-1 because the base loop counts from 1.
381
+ */
382
+ BuildPaginatedURL(basePath, obj, page, _offset, _cursor, effectivePageSize) {
383
+ const requested = effectivePageSize ?? obj.DefaultPageSize ?? DEFAULT_PAGE_SIZE;
384
+ const pageSize = Math.min(requested, NEON_MAX_PAGE_SIZE);
385
+ const separator = basePath.includes('?') ? '&' : '?';
386
+ // Base loop's `page` is 1-based; Neon is 0-based.
387
+ const neonPage = Math.max(0, page - 1);
388
+ return `${basePath}${separator}${PAGE_PARAM}=${neonPage}&${PAGE_SIZE_PARAM}=${pageSize}`;
389
+ }
390
+ /**
391
+ * Executes an HTTP request with retry/backoff for 429/503 and transient network errors.
392
+ * Parses Neon's Retry-After header into the error so ExtractRetryAfterMs can surface it.
393
+ * NOTE: retry is applied to GET/idempotent reads and to non-financial writes only — the
394
+ * CreateRecord override above handles the financial reconcile-before-retry contract.
395
+ */
396
+ async MakeHTTPRequest(auth, url, method, headers, body) {
397
+ const neon = auth;
398
+ const maxRetries = neon.Config?.MaxRetries ?? DEFAULT_MAX_RETRIES;
399
+ const timeoutMs = neon.Config?.RequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
400
+ const isWrite = method !== 'GET' && method !== 'HEAD';
401
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
402
+ const fetchOptions = {
403
+ method,
404
+ headers,
405
+ signal: AbortSignal.timeout(timeoutMs),
406
+ };
407
+ if (body !== undefined && isWrite) {
408
+ fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
409
+ }
410
+ let response;
411
+ try {
412
+ response = await fetch(url, fetchOptions);
413
+ }
414
+ catch (err) {
415
+ // Never auto-retry a WRITE on a network/timeout error — the request may have landed
416
+ // server-side (financial double-write risk). Surface to the caller (CreateRecord
417
+ // override) for reconcile-before-retry.
418
+ if (!isWrite && attempt < maxRetries && this.isTransientNetworkError(err)) {
419
+ await this.sleep(this.backoffDelay(attempt));
420
+ continue;
421
+ }
422
+ throw err;
423
+ }
424
+ if ((response.status === 429 || response.status === 503) && !isWrite && attempt < maxRetries) {
425
+ await this.sleep(this.retryAfterMs(response) ?? this.backoffDelay(attempt));
426
+ continue;
427
+ }
428
+ return this.buildRESTResponse(response);
429
+ }
430
+ throw new Error(`Neon CRM request failed after ${maxRetries + 1} attempts: ${url}`);
431
+ }
432
+ // ─── TestConnection ──────────────────────────────────────────────
433
+ /**
434
+ * Tests connectivity by listing one account via GET /accounts?currentPage=0&pageSize=1.
435
+ * A 2xx confirms the Basic credentials (org id + API key) + base URL are valid. 401/403 are
436
+ * surfaced with clear messages (auth failure path); a network error is surfaced too.
437
+ */
438
+ async TestConnection(companyIntegration, contextUser) {
439
+ try {
440
+ const auth = (await this.Authenticate(companyIntegration, contextUser));
441
+ const headers = this.BuildHeaders(auth);
442
+ const url = `${auth.BaseUrl.replace(/\/+$/, '')}/accounts?${PAGE_PARAM}=0&${PAGE_SIZE_PARAM}=1`;
443
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
444
+ if (response.Status === 401) {
445
+ return { Success: false, Message: 'Neon CRM authentication failed (HTTP 401) — check org id + API key.' };
446
+ }
447
+ if (response.Status === 403) {
448
+ return { Success: false, Message: 'Neon CRM authorization failed (HTTP 403) — the API key user lacks permission.' };
449
+ }
450
+ if (response.Status < 200 || response.Status >= 300) {
451
+ return { Success: false, Message: `Neon CRM returned HTTP ${response.Status} from ${url}` };
452
+ }
453
+ return {
454
+ Success: true,
455
+ Message: `Connected to Neon CRM at ${auth.BaseUrl}`,
456
+ ServerVersion: `Neon CRM API v${auth.Config.APIVersion}`,
457
+ };
458
+ }
459
+ catch (err) {
460
+ const message = err instanceof Error ? err.message : String(err);
461
+ return { Success: false, Message: `Connection failed: ${message}` };
462
+ }
463
+ }
464
+ // ─── Discovery (metadata-driven, no hardcoded catalog) ───────────
465
+ /**
466
+ * Discovers the full object universe from the IntegrationEngineBase cache (the Declared
467
+ * metadata seeded from Neon's credential-free OAS3 spec). NEVER a hardcoded catalog; never
468
+ * sampled at build. When no Declared metadata is loaded (a credential-free static self-check
469
+ * with no DB-backed engine), this throws explicitly so credential-free tiers SKIP honestly
470
+ * rather than misread an empty result as catalog drift.
471
+ */
472
+ async DiscoverObjects(companyIntegration, contextUser) {
473
+ const seeded = await super.DiscoverObjects(companyIntegration, contextUser);
474
+ if (seeded.length > 0)
475
+ return seeded;
476
+ throw new Error('Neon CRM DiscoverObjects requires the Declared metadata to be loaded into the ' +
477
+ 'IntegrationEngine cache (via `mj sync push`). The object catalog is runtime-seeded ' +
478
+ 'Declared metadata (from the credential-free OAS3 spec), not a statically reproducible ' +
479
+ 'code constant.');
480
+ }
481
+ /** Discovers fields for an object from the cached Declared metadata. */
482
+ async DiscoverFields(companyIntegration, objectName, contextUser) {
483
+ return super.DiscoverFields(companyIntegration, objectName, contextUser);
484
+ }
485
+ // ─── FetchChanges override (enumeration + watermark advancement) ─
486
+ /**
487
+ * Enumerates an object's records per the THREE metadata-driven enumeration modes (read from the
488
+ * IO's Configuration), then advances the watermark from the returned records on the FINAL batch
489
+ * only (partial-failure-safe — a mid-batch failure leaves the watermark unchanged so the next sync
490
+ * resumes from the same point). Watermark advancement reads the latest
491
+ * `timestamps.lastModifiedDateTime` (Neon's documented incremental cursor).
492
+ *
493
+ * Mode 1 — Direct collection, GET → delegate to the base GET pagination path (super.FetchChanges).
494
+ * Mode 2 — Direct collection, POST → POST the Configuration.ListBody to APIPath, paginate via body.
495
+ * Mode 3 — Access-path descent → list the DOOR, then descend the nesting chain in memory.
496
+ *
497
+ * Mode is inferred from the IO's parsed Configuration; a missing/GET-direct Configuration keeps the
498
+ * exact prior behavior (the 34 direct-GET objects ride the base path unchanged).
499
+ */
500
+ async FetchChanges(ctx) {
501
+ this.currentWatermark = ctx.WatermarkValue ?? undefined;
502
+ const result = await this.dispatchEnumeration(ctx);
503
+ const isFinal = !result.HasMore;
504
+ const newWatermark = isFinal
505
+ ? (this.extractLatestModifiedDate(result.Records) ?? ctx.WatermarkValue ?? undefined)
506
+ : undefined;
507
+ return { ...result, NewWatermarkValue: newWatermark };
508
+ }
509
+ /** Routes the fetch to the GET-direct base path, POST-search listing, or access-path descent. */
510
+ async dispatchEnumeration(ctx) {
511
+ const obj = this.GetCachedObject(ctx.CompanyIntegration.IntegrationID, ctx.ObjectName);
512
+ const cfg = this.parseObjectConfig(obj);
513
+ // Access-path descent: nesting is a real chain (not the direct-collection sentinel).
514
+ const nesting = cfg.AccessPath?.nesting;
515
+ if (nesting && nesting !== DIRECT_COLLECTION) {
516
+ return this.fetchViaAccessPath(ctx, obj, cfg);
517
+ }
518
+ // Direct collection via POST search.
519
+ if (this.isPostList(cfg)) {
520
+ return this.fetchViaPostSearch(ctx, obj, cfg);
521
+ }
522
+ // Direct collection via GET — the base GET pagination path already handles this correctly.
523
+ return super.FetchChanges(ctx);
524
+ }
525
+ // ─── Enumeration: parsing the per-IO Configuration ───────────────
526
+ /** Parses the IO's Configuration JSON into a typed NeonObjectConfig (GET-direct default on absence). */
527
+ parseObjectConfig(obj) {
528
+ const raw = obj.Configuration;
529
+ if (!raw || typeof raw !== 'string')
530
+ return { ListMethod: 'GET' };
531
+ let parsed;
532
+ try {
533
+ parsed = JSON.parse(raw);
534
+ }
535
+ catch {
536
+ return { ListMethod: 'GET' };
537
+ }
538
+ const accessPath = this.coerceAccessPath(parsed.AccessPath);
539
+ const listMethod = this.coerceString(parsed.ListMethod) ?? accessPath?.listMethod ?? 'GET';
540
+ return {
541
+ ListMethod: listMethod.toUpperCase(),
542
+ ListBody: this.coerceRecord(parsed.ListBody),
543
+ AccessPath: accessPath,
544
+ DetailAPIPath: this.coerceString(parsed.DetailAPIPath),
545
+ WatermarkSearchField: this.coerceString(parsed.WatermarkSearchField),
546
+ };
547
+ }
548
+ /** Narrows an unknown AccessPath blob to the typed shape (provable-only — undefined when absent). */
549
+ coerceAccessPath(raw) {
550
+ if (!raw || typeof raw !== 'object')
551
+ return undefined;
552
+ const ap = raw;
553
+ return {
554
+ door: this.coerceString(ap.door),
555
+ nesting: this.coerceString(ap.nesting),
556
+ listMethod: this.coerceString(ap.listMethod),
557
+ args: Array.isArray(ap.args) ? ap.args : undefined,
558
+ };
559
+ }
560
+ /** True when this object's door must be LISTED via POST (search). */
561
+ isPostList(cfg) {
562
+ const verb = (cfg.AccessPath?.listMethod ?? cfg.ListMethod).toUpperCase();
563
+ return verb === 'POST';
564
+ }
565
+ coerceString(v) {
566
+ return typeof v === 'string' && v.trim().length > 0 ? v.trim() : undefined;
567
+ }
568
+ coerceRecord(v) {
569
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : undefined;
570
+ }
571
+ // ─── Mode 2: direct collection via POST search ───────────────────
572
+ /**
573
+ * Lists a direct-collection object whose door is a POST `/…/search` endpoint. POSTs the
574
+ * Configuration.ListBody (parsed JSON) to APIPath, parses the same Neon envelope as GET, and
575
+ * paginates by injecting the page into the POST body's `pagination` object
576
+ * (`{"pagination":{"currentPage":N,"pageSize":M}}`). Returns a single full batch.
577
+ */
578
+ async fetchViaPostSearch(ctx, obj, cfg) {
579
+ const auth = (await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser));
580
+ const fields = this.GetCachedFields(obj.ID);
581
+ const door = cfg.AccessPath?.door ?? obj.APIPath;
582
+ // Server-side incremental narrowing: inject the GREATER_AND_EQUAL date criterion into the
583
+ // search body when a watermark is present AND this door has a resolvable last-modified search
584
+ // field (matrix C1 fix). On first sync (no watermark) this returns the body unchanged → full pull.
585
+ const listBody = this.buildIncrementalListBody(cfg, door, this.currentWatermark);
586
+ const raw = await this.listAllViaPost(auth, door, listBody, obj.DefaultPageSize ?? DEFAULT_PAGE_SIZE);
587
+ const pkFieldNames = this.findPKFieldNames(fields);
588
+ return {
589
+ Records: raw.map(r => this.buildExternalRecord(this.applyTransformPreservingKeys(r, obj, fields), ctx.ObjectName, pkFieldNames)),
590
+ HasMore: false,
591
+ };
592
+ }
593
+ // ─── Mode 3: access-path descent ─────────────────────────────────
594
+ /**
595
+ * Lists the DOOR (via GET or POST per the access path), then descends the in-memory nesting chain
596
+ * to collect the LEAF records of `obj`. Each leaf carries the FULL leaf object in `Fields`
597
+ * (full-record pass-through), is tagged with the resolved ancestor FK id(s), and uses its own
598
+ * declared PK for identity (the synthetic content-hash fallback covers PK-less leaves).
599
+ */
600
+ async fetchViaAccessPath(ctx, obj, cfg) {
601
+ const door = cfg.AccessPath?.door;
602
+ const nesting = cfg.AccessPath?.nesting;
603
+ if (!door || !nesting) {
604
+ return this.zeroWithWarning(ctx.ObjectName, 'ACCESS_PATH_INCOMPLETE', `"${ctx.ObjectName}": access-path Configuration missing door or nesting chain — cannot enumerate.`);
605
+ }
606
+ const auth = (await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser));
607
+ const fields = this.GetCachedFields(obj.ID);
608
+ const segments = this.parseNestingChain(nesting);
609
+ // The first chain segment names the door's record type, NOT a field to descend.
610
+ const descentSegments = segments.slice(1);
611
+ const doorRecords = await this.listDoor(auth, door, cfg, obj);
612
+ // Leaf IOFs whose RelatedIntegrationObjectID points at an ANCESTOR object — these get stamped
613
+ // with the originating ancestor's id (e.g. Consent.accountId ← the door Account's accountId).
614
+ const fkTagFields = this.resolveAncestorFKFields(fields);
615
+ const leaves = [];
616
+ const pkFieldNames = this.findPKFieldNames(fields);
617
+ for (const parent of doorRecords) {
618
+ // Resolve the DOOR (root ancestor) record's PK value(s) FIRST — for the Account door this
619
+ // requires the nested-id lift (DEFECT 1), so collectParentTags reads accountId reliably.
620
+ // The door's tags seed the accumulator that descends the whole chain (DEFECT 2).
621
+ const doorTags = this.collectParentTags(parent, fkTagFields);
622
+ for (const leaf of this.descendNesting(parent, descentSegments, fkTagFields, doorTags)) {
623
+ leaves.push(this.buildExternalRecord(this.applyTransformPreservingKeys(leaf, obj, fields), ctx.ObjectName, pkFieldNames));
624
+ }
625
+ }
626
+ if (leaves.length === 0) {
627
+ return this.zeroWithWarning(ctx.ObjectName, 'ACCESS_PATH_EMPTY', `"${ctx.ObjectName}": door "${door}" returned ${doorRecords.length} record(s) but the nesting chain "${nesting}" yielded no leaf records.`);
628
+ }
629
+ return { Records: leaves, HasMore: false };
630
+ }
631
+ /**
632
+ * Lists the access-path door, choosing GET or POST-search per the access path's listMethod.
633
+ * NOTE: NO server-side watermark filter is applied here. A date filter on the DOOR would narrow
634
+ * by the PARENT's last-modified date, which is NOT the LEAF object's watermark — filtering doors
635
+ * could drop leaf records whose owning parent wasn't modified recently (lossy). For nested/descent
636
+ * objects, content-hash narrowing remains the correct, lossless incremental strategy.
637
+ */
638
+ async listDoor(auth, door, cfg, obj) {
639
+ const pageSize = obj.DefaultPageSize ?? DEFAULT_PAGE_SIZE;
640
+ if (this.isPostList(cfg)) {
641
+ return this.listAllViaPost(auth, door, cfg.ListBody ?? {}, pageSize);
642
+ }
643
+ return this.listAllViaGet(auth, door, pageSize);
644
+ }
645
+ /** Splits a nesting chain string ("A -> b[] -> c") into typed segments (IsList for `[]`). */
646
+ parseNestingChain(nesting) {
647
+ return nesting.split('->').map(part => {
648
+ const token = part.trim();
649
+ const isList = token.endsWith('[]');
650
+ return { Name: isList ? token.slice(0, -2).trim() : token, IsList: isList };
651
+ }).filter(s => s.Name.length > 0);
652
+ }
653
+ /**
654
+ * Descends the nesting segments from a single door record IN MEMORY, collecting the leaf records,
655
+ * CARRYING the ancestor FK tags down the WHOLE chain (DEFECT 2). A `[]` segment expands an
656
+ * array-valued field (iterate each element); a plain segment dives into an object-valued field
657
+ * (single child). Records and primitives are skipped gracefully. Recurses so ≥2-level chains
658
+ * (e.g. pledges[] -> pledgePayments[]) are supported.
659
+ *
660
+ * `fkTagFields` is the leaf's set of ancestor-FK field names; `inheritedTags` is the accumulator
661
+ * seeded with the DOOR (root ancestor) record's resolved PK value(s). At EACH descended node we
662
+ * re-collect FK values present on that node (so a nearer ancestor — e.g. an intermediate Pledge —
663
+ * overrides a farther one), then at the leaf we stamp every accumulated tag the leaf does NOT
664
+ * already carry (the leaf's own value always wins). This makes the door's `accountId` reach a
665
+ * Consent leaf in `Account -> individualAccount -> consent`, while preserving the existing
666
+ * immediate-parent / 1-level behavior.
667
+ */
668
+ descendNesting(node, segments, fkTagFields, inheritedTags) {
669
+ if (segments.length === 0) {
670
+ if (!node || typeof node !== 'object' || Array.isArray(node))
671
+ return [];
672
+ return [this.stampInheritedTags(node, inheritedTags)];
673
+ }
674
+ if (!node || typeof node !== 'object' || Array.isArray(node))
675
+ return [];
676
+ const [head, ...rest] = segments;
677
+ const child = node[head.Name];
678
+ if (child == null)
679
+ return [];
680
+ const children = head.IsList
681
+ ? (Array.isArray(child) ? child : [])
682
+ : [child];
683
+ const out = [];
684
+ for (const c of children) {
685
+ // Merge any ancestor-FK values THIS node carries (nearer ancestor wins) before recursing.
686
+ const nextTags = this.mergeNodeTags(inheritedTags, c, fkTagFields);
687
+ for (const leaf of this.descendNesting(c, rest, fkTagFields, nextTags))
688
+ out.push(leaf);
689
+ }
690
+ return out;
691
+ }
692
+ /** Merges an intermediate node's own ancestor-FK values into the accumulator (node value wins). */
693
+ mergeNodeTags(inherited, node, fkTagFields) {
694
+ if (!node || typeof node !== 'object' || Array.isArray(node))
695
+ return inherited;
696
+ const nodeTags = this.collectParentTags(node, fkTagFields);
697
+ return Object.keys(nodeTags).length === 0 ? inherited : { ...inherited, ...nodeTags };
698
+ }
699
+ /** Stamps accumulated ancestor FK id(s) onto a leaf, never overwriting an id the leaf already carries. */
700
+ stampInheritedTags(leaf, tags) {
701
+ if (Object.keys(tags).length === 0)
702
+ return leaf;
703
+ const out = { ...leaf };
704
+ for (const [k, v] of Object.entries(tags)) {
705
+ if (out[k] == null || serializeKeyValue(out[k]).length === 0)
706
+ out[k] = v;
707
+ }
708
+ return out;
709
+ }
710
+ /**
711
+ * Resolves, from the leaf object's own IOFs, the FK field names that point at an ancestor object
712
+ * (RelatedIntegrationObjectID set). These are the columns to populate from an ancestor record so the
713
+ * leaf links back to its owner.
714
+ */
715
+ resolveAncestorFKFields(fields) {
716
+ return fields.filter(f => f.RelatedIntegrationObjectID).map(f => f.Name);
717
+ }
718
+ /**
719
+ * Reads each ancestor-FK value from an ancestor record so it can be stamped onto the leaf. Applies
720
+ * the nested-account-id lift first so the DOOR Account record's nested `accountId` (DEFECT 1) is
721
+ * found — then reads the matching FK key (e.g. `accountId`, `pledgeId`). Neon ancestor records carry
722
+ * their own id under a key whose name matches the leaf's FK field name.
723
+ */
724
+ collectParentTags(parent, fkFields) {
725
+ if (fkFields.length === 0)
726
+ return {};
727
+ const lifted = this.liftNestedAccountId(parent);
728
+ const tags = {};
729
+ for (const fk of fkFields) {
730
+ const v = lifted[fk];
731
+ if (v != null && (typeof v === 'string' || typeof v === 'number')) {
732
+ tags[fk] = String(v);
733
+ }
734
+ }
735
+ return tags;
736
+ }
737
+ // ─── Door listing primitives (GET + POST, full pagination) ───────
738
+ /** Lists a door via GET, paginating to exhaustion (Neon 0-based currentPage). Returns all records. */
739
+ async listAllViaGet(auth, door, pageSize) {
740
+ const headers = this.BuildHeaders(auth);
741
+ const baseURL = auth.BaseUrl.replace(/\/+$/, '');
742
+ const size = Math.min(pageSize, NEON_MAX_PAGE_SIZE);
743
+ const all = [];
744
+ let page = 0;
745
+ for (;;) {
746
+ const sep = door.includes('?') ? '&' : '?';
747
+ const url = `${baseURL}${door.startsWith('/') ? door : `/${door}`}${sep}${PAGE_PARAM}=${page}&${PAGE_SIZE_PARAM}=${size}`;
748
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
749
+ if (response.Status === 403)
750
+ break;
751
+ const records = this.NormalizeResponse(response.Body, null);
752
+ all.push(...records);
753
+ if (!this.hasMorePages(response.Body, page) || records.length === 0)
754
+ break;
755
+ page += 1;
756
+ }
757
+ return all;
758
+ }
759
+ /**
760
+ * Lists a door via POST search, paginating to exhaustion by injecting `{pagination:{currentPage,pageSize}}`
761
+ * into the POST body (Neon search pagination). Returns all records.
762
+ */
763
+ async listAllViaPost(auth, door, listBody, pageSize) {
764
+ const headers = this.BuildHeaders(auth);
765
+ const baseURL = auth.BaseUrl.replace(/\/+$/, '');
766
+ const url = `${baseURL}${door.startsWith('/') ? door : `/${door}`}`;
767
+ const size = Math.min(pageSize, NEON_MAX_PAGE_SIZE);
768
+ const all = [];
769
+ let page = 0;
770
+ for (;;) {
771
+ const body = this.buildSearchBody(listBody, page, size);
772
+ const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, body);
773
+ if (response.Status === 403)
774
+ break;
775
+ const records = this.NormalizeResponse(response.Body, null);
776
+ all.push(...records);
777
+ if (!this.hasMorePages(response.Body, page) || records.length === 0)
778
+ break;
779
+ page += 1;
780
+ }
781
+ return all;
782
+ }
783
+ /** Builds the POST search body with the page injected into the `pagination` envelope. */
784
+ buildSearchBody(listBody, page, pageSize) {
785
+ const existing = this.coerceRecord(listBody[SEARCH_PAGINATION_KEY]) ?? {};
786
+ return {
787
+ ...listBody,
788
+ [SEARCH_PAGINATION_KEY]: { ...existing, currentPage: page, pageSize },
789
+ };
790
+ }
791
+ // ─── Server-side incremental narrowing (POST-search doors) ───────────
792
+ /**
793
+ * Builds the effective POST-search `ListBody` for the incremental pass: when a watermark is
794
+ * present AND the door has a resolvable last-modified SEARCH FIELD, appends a
795
+ * `{ field, operator: GREATER_AND_EQUAL, value }` criterion to `searchFields` (the OAS3
796
+ * SearchRequest shape) so the API returns ONLY records changed at/after the watermark. On first
797
+ * sync (no watermark) — or for a door with no documented search field — returns the configured
798
+ * ListBody unchanged (full pull; content-hash narrowing remains the lossless fallback).
799
+ *
800
+ * Provable-only: a criterion is emitted ONLY when the search-field name is known — from the
801
+ * per-connection `Configuration.WatermarkSearchField` override or the documented
802
+ * {@link DOOR_WATERMARK_SEARCH_FIELD} map. A criterion is NEVER appended to a `searchFields` the
803
+ * connector already carries one for (idempotent), and the existing authored searchFields are
804
+ * preserved.
805
+ */
806
+ buildIncrementalListBody(cfg, door, watermark) {
807
+ const base = cfg.ListBody ?? {};
808
+ if (!watermark)
809
+ return base; // first sync → no filter → full pull
810
+ const searchField = this.resolveWatermarkSearchField(cfg, door);
811
+ if (!searchField)
812
+ return base; // no documented server-side filter → content-hash fallback
813
+ const value = this.formatNeonSearchDate(watermark);
814
+ if (!value)
815
+ return base; // unparseable watermark → don't fabricate a criterion
816
+ const existing = Array.isArray(base[SEARCH_FIELDS_KEY])
817
+ ? base[SEARCH_FIELDS_KEY]
818
+ : [];
819
+ // Idempotency: don't double-append if a criterion for this field is already present.
820
+ if (existing.some(c => c && typeof c === 'object' && c.field === searchField))
821
+ return base;
822
+ const criterion = { field: searchField, operator: SEARCH_OP_GREATER_AND_EQUAL, value };
823
+ return { ...base, [SEARCH_FIELDS_KEY]: [...existing, criterion] };
824
+ }
825
+ /**
826
+ * Resolves the door's last-modified SEARCH FIELD display name (the OAS3 SearchCriteria.field
827
+ * value), preferring the per-connection `Configuration.WatermarkSearchField` override, then the
828
+ * documented {@link DOOR_WATERMARK_SEARCH_FIELD} map keyed by the door path. Returns undefined
829
+ * when neither knows the door (⇒ no server-side filter).
830
+ */
831
+ resolveWatermarkSearchField(cfg, door) {
832
+ if (cfg.WatermarkSearchField)
833
+ return cfg.WatermarkSearchField;
834
+ const normalized = door.startsWith('/') ? door : `/${door}`;
835
+ return DOOR_WATERMARK_SEARCH_FIELD[normalized];
836
+ }
837
+ /**
838
+ * Formats an ISO watermark timestamp to Neon's documented date-search value (`yyyy-MM-dd`).
839
+ * Neon date search fields filter at day granularity; a date-only `>=` is a safe, slightly
840
+ * conservative bound that never drops a same-day record (the engine still re-narrows via
841
+ * content hash). Returns undefined for an unparseable watermark.
842
+ */
843
+ formatNeonSearchDate(watermark) {
844
+ const d = new Date(watermark);
845
+ if (isNaN(d.getTime()))
846
+ return undefined;
847
+ return d.toISOString().slice(0, 10);
848
+ }
849
+ /** Reads the Neon pagination envelope to decide whether another door page remains (0-based). */
850
+ hasMorePages(rawBody, currentPage) {
851
+ if (!rawBody || typeof rawBody !== 'object')
852
+ return false;
853
+ const pagination = rawBody.pagination;
854
+ if (pagination && typeof pagination.totalPages === 'number') {
855
+ const serverPage = typeof pagination.currentPage === 'number' ? pagination.currentPage : currentPage;
856
+ return serverPage + 1 < pagination.totalPages;
857
+ }
858
+ return false;
859
+ }
860
+ // ─── ExternalRecord assembly (mirrors the base's identity logic) ─
861
+ /** Returns the declared PK field names (by Sequence) or ['ID'] fallback — mirrors the base helper. */
862
+ findPKFieldNames(fields) {
863
+ const pk = fields.filter(f => f.IsPrimaryKey).sort((a, b) => a.Sequence - b.Sequence);
864
+ return pk.length > 0 ? pk.map(f => f.Name) : ['ID'];
865
+ }
866
+ /**
867
+ * Builds an ExternalRecord with the SAME identity semantics the base uses: declared PK when every
868
+ * component is present + non-empty, else a deterministic content hash (the synthetic-PK fallback for
869
+ * PK-less / partial-key leaves). The FULL record passes through in `Fields` (full-record pass-through).
870
+ */
871
+ buildExternalRecord(raw, objectType, pkFieldNames) {
872
+ const allPkPresent = pkFieldNames.length > 0
873
+ && pkFieldNames.every(name => raw[name] != null && serializeKeyValue(raw[name]).length > 0);
874
+ const joined = pkFieldNames.map(name => serializeKeyValue(raw[name])).join('|');
875
+ const resolvedID = allPkPresent ? joined : computeContentHash(raw);
876
+ // Stamp the synthetic identity into a single empty PK so the codegen reload-by-PK finds the row
877
+ // (matches the base's §4 single-PK fallback). Full record otherwise preserved.
878
+ let fields = raw;
879
+ if (!allPkPresent && pkFieldNames.length === 1
880
+ && (raw[pkFieldNames[0]] == null || serializeKeyValue(raw[pkFieldNames[0]]).length === 0)) {
881
+ fields = { ...raw, [pkFieldNames[0]]: resolvedID };
882
+ }
883
+ return { ExternalID: resolvedID, ObjectType: objectType, Fields: fields };
884
+ }
885
+ /** A zero-record batch carrying a structured FetchWarning so the silent-empty is surfaced. */
886
+ zeroWithWarning(objectName, code, message) {
887
+ return { Records: [], HasMore: false, Warnings: [{ Code: code, Message: message, Data: { object: objectName } }] };
888
+ }
889
+ // ─── Config parsing ──────────────────────────────────────────────
890
+ /**
891
+ * Parses the connection config, preferring the attached MJ Credential over the raw
892
+ * Configuration JSON. Credential bytes are resolved at runtime — never at build.
893
+ */
894
+ async ParseConfig(companyIntegration, contextUser) {
895
+ if (companyIntegration.CredentialID) {
896
+ return this.parseConfigFromCredential(companyIntegration.CredentialID, contextUser);
897
+ }
898
+ if (companyIntegration.Configuration) {
899
+ return this.validateConfig(JSON.parse(companyIntegration.Configuration));
900
+ }
901
+ throw new Error('Neon CRM connector requires either CredentialID or Configuration JSON');
902
+ }
903
+ /** Loads the config from the MJ: Credentials entity Values JSON. */
904
+ async parseConfigFromCredential(credentialID, contextUser, provider) {
905
+ const md = provider ?? new Metadata();
906
+ const cred = await md.GetEntityObject('MJ: Credentials', contextUser);
907
+ const loaded = await cred.Load(credentialID);
908
+ if (!loaded || !cred.Values) {
909
+ throw new Error('Neon CRM credential could not be loaded or has no Values JSON');
910
+ }
911
+ return this.validateConfig(JSON.parse(cred.Values));
912
+ }
913
+ /** Validates the parsed config + applies defaults. Field names are case-insensitive. */
914
+ validateConfig(raw) {
915
+ if (!raw || typeof raw !== 'object') {
916
+ throw new Error('Neon CRM configuration is not a valid object');
917
+ }
918
+ const obj = raw;
919
+ const getStr = (...keys) => {
920
+ for (const key of keys) {
921
+ const lower = key.toLowerCase();
922
+ for (const [k, v] of Object.entries(obj)) {
923
+ if (k.toLowerCase() === lower && typeof v === 'string' && v.length > 0)
924
+ return v;
925
+ }
926
+ }
927
+ return undefined;
928
+ };
929
+ const getNum = (...keys) => {
930
+ for (const key of keys) {
931
+ const lower = key.toLowerCase();
932
+ for (const [k, v] of Object.entries(obj)) {
933
+ if (k.toLowerCase() === lower && typeof v === 'number')
934
+ return v;
935
+ }
936
+ }
937
+ return undefined;
938
+ };
939
+ const orgID = getStr('orgid', 'org_id', 'organizationid', 'organization_id', 'username');
940
+ if (!orgID) {
941
+ throw new Error('Neon CRM configuration missing required field: OrgID');
942
+ }
943
+ const apiKey = getStr('apikey', 'api_key', 'key', 'password');
944
+ if (!apiKey) {
945
+ throw new Error('Neon CRM configuration missing required field: APIKey');
946
+ }
947
+ return {
948
+ OrgID: orgID,
949
+ APIKey: apiKey,
950
+ BaseURL: (getStr('baseurl', 'base_url') ?? DEFAULT_BASE_URL).replace(/\/+$/, ''),
951
+ APIVersion: getStr('apiversion', 'api_version', 'neonapiversion') ?? DEFAULT_API_VERSION,
952
+ MaxRetries: getNum('maxretries') ?? DEFAULT_MAX_RETRIES,
953
+ RequestTimeoutMs: getNum('requesttimeoutms') ?? DEFAULT_REQUEST_TIMEOUT_MS,
954
+ };
955
+ }
956
+ // ─── Helpers ─────────────────────────────────────────────────────
957
+ /** Extracts the latest timestamps.lastModifiedDateTime across a batch for watermark advancement. */
958
+ extractLatestModifiedDate(records) {
959
+ let latest = null;
960
+ for (const rec of records) {
961
+ const raw = this.readLastModified(rec.Fields);
962
+ if (typeof raw !== 'string' || raw.length === 0)
963
+ continue;
964
+ const d = new Date(raw);
965
+ if (!isNaN(d.getTime()) && (latest === null || d > latest))
966
+ latest = d;
967
+ }
968
+ return latest ? latest.toISOString() : null;
969
+ }
970
+ /** Reads timestamps.lastModifiedDateTime (Neon's nested cursor) or a flat top-level fallback. */
971
+ readLastModified(fields) {
972
+ const ts = fields.timestamps;
973
+ if (ts && typeof ts === 'object') {
974
+ const v = ts.lastModifiedDateTime;
975
+ if (typeof v === 'string')
976
+ return v;
977
+ }
978
+ // Grants expose top-level lastModifiedDate per the Configuration notes.
979
+ for (const k of ['lastModifiedDateTime', 'lastModifiedDate', 'modifiedDate']) {
980
+ const v = fields[k];
981
+ if (typeof v === 'string')
982
+ return v;
983
+ }
984
+ return undefined;
985
+ }
986
+ /** Parses a Retry-After header (seconds or http-date) into ms, if present. */
987
+ retryAfterMs(response) {
988
+ const header = response.headers.get('retry-after');
989
+ if (!header)
990
+ return undefined;
991
+ const asSeconds = Number(header);
992
+ if (!isNaN(asSeconds))
993
+ return Math.max(0, asSeconds * 1_000);
994
+ const asDate = new Date(header).getTime();
995
+ if (!isNaN(asDate))
996
+ return Math.max(0, asDate - Date.now());
997
+ return undefined;
998
+ }
999
+ /** Exponential backoff delay for retry attempts (capped at 30s). */
1000
+ backoffDelay(attempt) {
1001
+ return Math.min(1_000 * Math.pow(2, attempt), 30_000);
1002
+ }
1003
+ /** Checks whether an error is transient (network/timeout). */
1004
+ isTransientNetworkError(err) {
1005
+ if (!(err instanceof Error))
1006
+ return false;
1007
+ const msg = err.message.toLowerCase();
1008
+ return msg.includes('timeout') || msg.includes('abort') ||
1009
+ msg.includes('econnreset') || msg.includes('econnrefused') ||
1010
+ msg.includes('fetch failed');
1011
+ }
1012
+ /** Builds the normalized RESTResponse from a fetch Response. */
1013
+ async buildRESTResponse(response) {
1014
+ const headers = {};
1015
+ response.headers.forEach((v, k) => { headers[k.toLowerCase()] = v; });
1016
+ const text = await response.text();
1017
+ let body = null;
1018
+ if (text.length > 0) {
1019
+ try {
1020
+ body = JSON.parse(text);
1021
+ }
1022
+ catch {
1023
+ body = text;
1024
+ }
1025
+ }
1026
+ return { Status: response.status, Body: body, Headers: headers };
1027
+ }
1028
+ /** Promise-wrapped setTimeout. */
1029
+ sleep(ms) {
1030
+ return new Promise(resolve => setTimeout(resolve, ms));
1031
+ }
1032
+ };
1033
+ NeonCRMConnector = __decorate([
1034
+ RegisterClass(BaseIntegrationConnector, '@memberjunction/connector-neon-crm')
1035
+ ], NeonCRMConnector);
1036
+ export { NeonCRMConnector };
1037
+ /** Tree-shaking prevention — import and call from the package entry point. */
1038
+ export function LoadNeonCRMConnector() { }
1039
+ //# sourceMappingURL=NeonCRMConnector.js.map