@memberjunction/connector-elevate 0.2.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,1490 @@
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
+ import { RegisterClass } from '@memberjunction/global';
8
+ import { Metadata } from '@memberjunction/core';
9
+ import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
10
+ import { BaseIntegrationConnector, BaseRESTIntegrationConnector, ClassifyError, computeContentHash, serializeKeyValue, } from '@memberjunction/integration-engine';
11
+ import { mergeDeclaredWithSampledFields } from '@memberjunction/connector-schema-merge';
12
+ // ─── Design note — WHY THE GENERIC REST READ PATH IS OVERRIDDEN ────────────────
13
+ //
14
+ // Elevate's Report API is NOT a REST collection API and the base class's generic per-operation
15
+ // GET-list CRUD does not fit it. Every declared object is read through ONE endpoint
16
+ // (`POST <siteUrl>/api/reports`) and the object is chosen by a BODY field, not by a URL path
17
+ // segment or query param. `Configuration.ReadContract.differsFromGenericCRUD` (metadata) states
18
+ // this verbatim: "exactly ONE endpoint serves every object, the HTTP method is POST (not GET),
19
+ // object selection is a BODY field ... and column selection is an explicit `fields` allow-list".
20
+ // A generic `GET <APIPath>` emission answers 301/404/405 on EVERY object, so `FetchChanges` is
21
+ // overridden here. That is an evidenced idiosyncrasy, not a shortcut — the WRITE surface stays on
22
+ // the base class's generic per-operation slots (see CreateRecord, which is NOT overridden).
23
+ //
24
+ // The four things this connector must get right, all of them metadata-routed:
25
+ //
26
+ // 1. THE ENVELOPE. Every read POSTs { api_key, format, resource, fields, filters } to the single
27
+ // door. `resource` comes from the IO's `Configuration.resourceWireValue` (e.g. 'accountingCode',
28
+ // which the RealityProbe proved correct against the vendor's own prose spelling 'accountCode');
29
+ // the door path comes from `IntegrationObject.APIPath` ('/api/reports' — the server's own 301
30
+ // Location target, NOT the documented trailing-slash form). Nothing is guessed in code.
31
+ //
32
+ // 2. THE FIELD ALLOW-LIST IS NOT PASS-THROUGH. The door returns ONLY the columns the request asks
33
+ // for, and rejects the WHOLE query (HTTP 500) for one unrecognised name. The selector is built
34
+ // from the object's declared IOFs — using each IOF's `Configuration.wireSelector`, which carries
35
+ // the vendor's DOT-PATH form ('product.title', 'user.member_id') rather than the MJ column name —
36
+ // UNIONED with the field names runtime discovery has learned for THIS connection from the door's
37
+ // own `response.labels` dictionary AND then PROVEN acceptable by an out-of-band probe. A hardcoded
38
+ // list would truncate every record to the build-time guess without ever showing up as an error;
39
+ // an UNPROVEN learned name in a data read would put every row of the object behind a guess, because
40
+ // the allow-list is all-or-nothing. Discovery is speculative; the data read never is.
41
+ //
42
+ // 3. FORMAT IS json WHENEVER A DOT-PATH SELECTOR IS USED. The vendor's own words: "CSV, being a
43
+ // 'flat' data format, is limited to only the values in the top-most layer of the API response".
44
+ // csv is opt-in per connection AND refused when the selection is not flat.
45
+ //
46
+ // 4. BULK IS DATE-WINDOWED, NOT PAGED. The RealityProbe DECIDED pagination NEGATIVE at volume
47
+ // (29,003 rows unfiltered == the sum of 15 all-HTTP-200 yearly windows; limit/offset/page/
48
+ // per_page/page_size all inert), so no paging scheme is invented here. Large pulls are chunked
49
+ // into consecutive non-overlapping windows on the object's DECLARED date field via
50
+ // `{"<field>":{"date":[from,to]}}` — the exact filter shape the probe partitioned with — and every
51
+ // single read verifies the door's own `response.count` against `response.items.length`, which is
52
+ // the silent-truncation tripwire above the volume actually probed.
53
+ //
54
+ // There is NO vendor host: `GetBaseURL` derives strictly from the connection's own `siteUrl`. The
55
+ // vendor's published demo host and demo key are probe-only artefacts and appear NOWHERE in this file.
56
+ // The api_key travels in the request BODY, is injected at the single transport choke point, and is
57
+ // never logged, never embedded in an error message, and never written to a fixture.
58
+ /** Vendor-documented cap on how many times a rejected field name is dropped before the read gives up. */
59
+ const MAX_SELECTOR_REPAIRS = 8;
60
+ /** Maximum times a single windowed query is halved when the door reports truncation. */
61
+ const MAX_WINDOW_SPLIT_DEPTH = 12;
62
+ /** Default window span, in days, for a chunked bulk pull — the probe partitioned by year. */
63
+ const DEFAULT_WINDOW_DAYS = 365;
64
+ /** Maximum 429 retries per request. A 500 from this door is a CLIENT error and is NEVER retried. */
65
+ const MAX_THROTTLE_RETRIES = 3;
66
+ /** Upper bound on any single honoured `Retry-After` sleep, so a hostile header cannot wedge a sync. */
67
+ const MAX_RETRY_AFTER_MS = 60_000;
68
+ /** Bound on the create-side `product_url` side-channel, so a long push cannot grow it without limit. */
69
+ const MAX_TRACKED_PRODUCT_URLS = 1_000;
70
+ /**
71
+ * Typed transport failure. Carries the response headers so `ExtractRetryAfterMs` can honour
72
+ * `Retry-After` off the error the engine sees, and the classification so callers do not re-parse.
73
+ */
74
+ export class ElevateAPIError extends Error {
75
+ constructor(message, Status, Headers, Classification) {
76
+ super(message);
77
+ this.Status = Status;
78
+ this.Headers = Headers;
79
+ this.Classification = Classification;
80
+ this.name = 'ElevateAPIError';
81
+ }
82
+ }
83
+ /**
84
+ * Elevate LMS (Cadmium) connector.
85
+ *
86
+ * Reads ride the Report API's single POST door with a JSON envelope; writes ride the base class's
87
+ * generic per-operation CRUD slots against the Registration API. Auth is a per-client `api_key`
88
+ * carried in the request BODY.
89
+ */
90
+ let ElevateConnector = class ElevateConnector extends BaseRESTIntegrationConnector {
91
+ constructor() {
92
+ super(...arguments);
93
+ /** Resolved auth per CompanyIntegration.ID. The api_key is static and has no refresh endpoint. */
94
+ this.authCache = new Map();
95
+ /** Field names the door's own `response.labels` dictionary has revealed, per connection+object. */
96
+ this.discoveredFieldNames = new Map();
97
+ /**
98
+ * Learned field names this connection's door has PROVEN it accepts on a read, per connection+object.
99
+ * Only these are ever allowed to join a DATA read's `fields` allow-list — see {@link VerifyLearnedFields}.
100
+ */
101
+ this.verifiedFieldNames = new Map();
102
+ /** One sampled value per discovered field name, used ONLY for runtime type inference. */
103
+ this.discoveredSamples = new Map();
104
+ /** Field names this connection's door REJECTED — never requested again for that object. */
105
+ this.rejectedFieldNames = new Map();
106
+ /** Declared resources this connection accepted a probe query for. Absence NEVER deactivates. */
107
+ this.validatedResources = new Map();
108
+ /**
109
+ * `product_url` returned alongside a created registration, keyed by the new registration_id — the
110
+ * learner's link, which the generic `CRUDResult` has no slot for. Bounded (oldest evicted) so a long
111
+ * push cannot grow it without limit.
112
+ */
113
+ this.LastCreatedProductURLs = new Map();
114
+ /** Warnings already emitted, so the log stays honest rather than noisy. */
115
+ this.warnedOnce = new Set();
116
+ }
117
+ // ── Identity (T1 three-way invariant) ─────────────────────────────────────
118
+ /** Verbatim `MJ: Integrations.Name`. Load-bearing: T1 compares this === the metadata Name. */
119
+ get IntegrationName() {
120
+ return 'elevate';
121
+ }
122
+ // ── Capability getters (kept in lockstep with the per-operation IO columns) ──
123
+ /** POST /api/registrations exists (productRegistration only) — `CreateAPIPath`/`CreateMethod` are populated. */
124
+ get SupportsCreate() { return true; }
125
+ /**
126
+ * FALSE. `Configuration.WriteCapability.update` (metadata): "No update endpoint is documented for
127
+ * any resource anywhere in the corpus." No IO carries `UpdateAPIPath`. `UpdateRecord` below fails
128
+ * loudly rather than no-opping or degrading into a create.
129
+ */
130
+ get SupportsUpdate() { return false; }
131
+ /**
132
+ * TRUE only in the sense the metadata declares: `POST /registrations/cancel` is a domain-specific
133
+ * CANCEL on one productRegistration, not a generic hard DELETE, and it is NOT reflected on the
134
+ * read side (`Configuration.DeleteSemantics` = 'none'). Deletion RECONCILIATION therefore needs a
135
+ * periodic key sweep — this connector never claims a delete FEED.
136
+ */
137
+ get SupportsDelete() { return true; }
138
+ /**
139
+ * FALSE, permanently. `Configuration.DiscoveryAuthoritativeness` records `no-describe-endpoint` at
140
+ * BOTH object and field level with `deactivationPermitted: false`. With no describe endpoint,
141
+ * absence at runtime proves nothing: a thin query result must never deactivate a persisted object
142
+ * or field, which would be tenant-visible data loss.
143
+ */
144
+ get DiscoveryIsAuthoritative() {
145
+ return false;
146
+ }
147
+ // ── Sync-efficiency hooks (§7/§10) ────────────────────────────────────────
148
+ /**
149
+ * Rate limiting provably EXISTS (Elevate Release 2025.02, verbatim: "Introduced rate limiting for
150
+ * Report API requests") but is UNQUANTIFIED — no ceiling, window, burst or `Retry-After` format is
151
+ * documented anywhere. The only empirical evidence is the RealityProbe's own pacing result recorded
152
+ * in `Configuration.PaginationDefaultsNote`: a pass at ~1.1s between requests was rate-limited
153
+ * (429s on 4 windows); the re-paced pass at ~3.4s completed 15 windows all HTTP 200. That is an
154
+ * OBSERVATION, not a vendor commitment, so the sustained rate published here is deliberately below
155
+ * the slower of the two (≈0.29/s) and the engine's AIMD limiter does the pacing — this connector
156
+ * never sleeps on its own. Burst 1: the door serves one query per request, there is nothing to batch.
157
+ */
158
+ get RateLimitPolicy() {
159
+ return {
160
+ TokensPerSec: 0.29,
161
+ Burst: 1,
162
+ ThrottleBackoffFactor: 0.5,
163
+ SuccessRampPerCall: 0.02,
164
+ MinTokensPerSec: 0.05,
165
+ };
166
+ }
167
+ /** One in flight. The limit exists, its numbers do not, and the door is a reporting engine. */
168
+ get MaxConcurrencyHint() { return 1; }
169
+ /**
170
+ * Honours `Retry-After` (delta-seconds AND the HTTP-date form) off the typed error the transport
171
+ * threw, so the engine's AIMD bucket backs off by the vendor's own instruction. Returns undefined
172
+ * when the response carried no header — the metadata records `retryAfterHeaderDocumented: false`,
173
+ * so the header's absence is expected and must not be papered over with an invented number.
174
+ */
175
+ ExtractRetryAfterMs(error) {
176
+ if (!(error instanceof ElevateAPIError))
177
+ return undefined;
178
+ if (error.Status !== 429 && error.Status !== 503)
179
+ return undefined;
180
+ const raw = error.Headers['retry-after'];
181
+ if (raw == null)
182
+ return error.Status === 429 ? 1_000 : undefined;
183
+ const seconds = Number(raw);
184
+ if (Number.isFinite(seconds) && seconds >= 0)
185
+ return Math.min(MAX_RETRY_AFTER_MS, Math.ceil(seconds * 1000));
186
+ const when = Date.parse(String(raw));
187
+ if (Number.isFinite(when))
188
+ return Math.min(MAX_RETRY_AFTER_MS, Math.max(0, when - Date.now()));
189
+ return undefined;
190
+ }
191
+ /**
192
+ * Strictly whatever the metadata declares. Only `Product` still carries one (`id`, probe-confirmed
193
+ * populated 1985/1985); `User.member_id` and `AccountingCode.id` were WITHDRAWN by the RealityProbe
194
+ * because they are not populated on every row, and a null-bearing column is not a resume cursor.
195
+ * Never synthesised here.
196
+ */
197
+ StableOrderingKey(objectName) {
198
+ const integrationID = this.tryGetIntegrationID();
199
+ if (!integrationID)
200
+ return null;
201
+ try {
202
+ return this.GetCachedObject(integrationID, objectName).StableOrderingKey ?? null;
203
+ }
204
+ catch {
205
+ return null;
206
+ }
207
+ }
208
+ // ── Discovery (runtime, additive, NEVER deactivating) ─────────────────────
209
+ /**
210
+ * The declared catalog is the FLOOR, not the ceiling and not a code constant: it is seeded into the
211
+ * engine cache from `metadata/integrations/elevate/.elevate.integration.json` and read back here via
212
+ * the base implementation. On top of that floor this method VALIDATES each declared resource against
213
+ * THIS connection with a minimal probe query — accepted ⇒ present.
214
+ *
215
+ * A probe that fails NEVER removes an object. `DiscoveryIsAuthoritative` is false and the metadata
216
+ * records `deactivationPermitted: false`: with no describe endpoint, a rejection can mean a
217
+ * per-tenant permission, a transient fault, or a genuinely absent resource, and those are not
218
+ * distinguishable. The rejection is surfaced as a loud, once-per-connection warning instead.
219
+ */
220
+ async DiscoverObjects(companyIntegration, contextUser) {
221
+ const declared = [];
222
+ for (const obj of this.getCachedObjects(companyIntegration.IntegrationID)) {
223
+ await this.ValidateResource(companyIntegration, contextUser, obj.Name);
224
+ declared.push({
225
+ ID: obj.ID,
226
+ Name: obj.Name,
227
+ Label: obj.DisplayName ?? obj.Name,
228
+ Description: obj.Description ?? undefined,
229
+ SupportsIncrementalSync: obj.SupportsIncrementalSync,
230
+ SupportsWrite: obj.SupportsWrite,
231
+ });
232
+ }
233
+ return declared;
234
+ }
235
+ /**
236
+ * Two-stage, additive. Stage 1 is the declared floor from the engine cache (the base implementation).
237
+ * Stage 2 probes THIS connection: every Report API response carries a `response.labels` dictionary
238
+ * keying the resource's FULL retrievable field set to its display label — returned irrespective of
239
+ * which columns the request asked for — so a site's configured custom/profile fields are reachable
240
+ * per tenant without any describe endpoint. Discovered-only fields are appended, never substituted,
241
+ * and a declared field is never dropped because a probe did not see it.
242
+ */
243
+ async DiscoverFields(companyIntegration, objectName, contextUser) {
244
+ const declared = await super.DiscoverFields(companyIntegration, objectName, contextUser);
245
+ await this.LearnFieldsFromSource(companyIntegration, contextUser, objectName);
246
+ // A declared column is claimed under BOTH names: its MJ column name (`product_title`) and the
247
+ // dot-path wire selector the door labels it by (`product.title`). Matching on only one of them
248
+ // would re-emit every projected column a second time under its wire name.
249
+ const claimed = new Set();
250
+ for (const f of declared)
251
+ claimed.add(f.Name.toLowerCase());
252
+ try {
253
+ const obj = this.GetCachedObject(companyIntegration.IntegrationID, objectName);
254
+ for (const col of this.ReadColumnsFor(this.GetCachedFields(obj.ID))) {
255
+ claimed.add(col.Name.toLowerCase());
256
+ claimed.add(col.WireSelector.toLowerCase());
257
+ }
258
+ }
259
+ catch {
260
+ /* no cached object → the `declared` names above are all we can claim. */
261
+ }
262
+ const key = this.CacheKey(companyIntegration, objectName);
263
+ const samples = this.discoveredSamples.get(key);
264
+ const rejected = this.rejectedFieldNames.get(key) ?? new Set();
265
+ const extra = [];
266
+ for (const name of this.SortedNames(this.discoveredFieldNames.get(key))) {
267
+ if (claimed.has(name.toLowerCase()) || rejected.has(name))
268
+ continue;
269
+ extra.push(this.SchemaForDiscoveredField(name, samples?.get(name)));
270
+ }
271
+ return [...declared, ...extra];
272
+ }
273
+ /**
274
+ * Declared ∪ runtime-discovered, so a tenant's own columns reach the schema builder. Delegates the
275
+ * union to the shared `mergeDeclaredWithSampledFields` helper (never-shrink by field name); the
276
+ * connector supplies no merge logic of its own.
277
+ */
278
+ async IntrospectSchema(companyIntegration, contextUser) {
279
+ const info = await super.IntrospectSchema(companyIntegration, contextUser);
280
+ await Promise.all(info.Objects.map(async (obj) => {
281
+ try {
282
+ const discovered = await this.DiscoverFields(companyIntegration, obj.ExternalName, contextUser);
283
+ obj.Fields = mergeDeclaredWithSampledFields(obj.Fields, discovered);
284
+ }
285
+ catch (err) {
286
+ this.WarnOnce(`introspect:${obj.ExternalName}`, `[elevate] Runtime field discovery for "${obj.ExternalName}" failed (${this.SafeMessage(err)}); ` +
287
+ `the DECLARED field floor still stands. Nothing was removed — absence proves nothing on a ` +
288
+ `source with no describe endpoint.`);
289
+ }
290
+ }));
291
+ return info;
292
+ }
293
+ // ── Connection test ───────────────────────────────────────────────────────
294
+ /**
295
+ * Runs the cheapest real read the door supports: a minimal single-column query against the first
296
+ * declared resource. A 200 with the documented envelope proves the site URL, the api_key and the
297
+ * door are all good together. The message never carries credential bytes.
298
+ */
299
+ async TestConnection(companyIntegration, contextUser) {
300
+ try {
301
+ const objects = this.getCachedObjects(companyIntegration.IntegrationID);
302
+ if (objects.length === 0) {
303
+ return {
304
+ Success: false,
305
+ Message: '[elevate] No ACTIVE IntegrationObjects are seeded for this integration, so there is no ' +
306
+ 'resource to probe. Push metadata/integrations/elevate before testing the connection.',
307
+ };
308
+ }
309
+ const probed = objects[0].Name;
310
+ const accepted = await this.ValidateResource(companyIntegration, contextUser, probed);
311
+ if (!accepted) {
312
+ return {
313
+ Success: false,
314
+ Message: `[elevate] The Report API door rejected a minimal query for resource "${probed}". ` +
315
+ 'Check the site URL and the API key issued for this site.',
316
+ };
317
+ }
318
+ return {
319
+ Success: true,
320
+ Message: `[elevate] Report API reachable at the configured site; resource "${probed}" answered a ` +
321
+ 'minimal query.',
322
+ };
323
+ }
324
+ catch (err) {
325
+ return { Success: false, Message: `[elevate] Connection test failed: ${this.SafeMessage(err)}` };
326
+ }
327
+ }
328
+ // ── The READ path — POST envelope to ONE door, date-windowed ──────────────
329
+ /**
330
+ * OVERRIDDEN because Elevate's reads are a POST-with-a-JSON-envelope to a SINGLE endpoint with the
331
+ * object chosen by a body field — the base class's generic per-operation GET-list CRUD would 404/405
332
+ * on every object. Everything that varies per object (door path, `resource` wire value, response
333
+ * keys, watermark field, window field) is routed FROM METADATA; nothing is a string guess in code.
334
+ */
335
+ async FetchChanges(ctx) {
336
+ const companyIntegration = ctx.CompanyIntegration;
337
+ const obj = this.GetCachedObject(companyIntegration.IntegrationID, ctx.ObjectName);
338
+ const iofs = this.GetCachedFields(obj.ID);
339
+ const auth = await this.Authenticate(companyIntegration, ctx.ContextUser);
340
+ const route = this.ReadRouteFor(obj);
341
+ const columns = this.ReadColumnsFor(iofs);
342
+ const warnings = [];
343
+ const windowField = this.WindowFieldFor(obj);
344
+ const plan = this.BuildWindowPlan(companyIntegration, ctx, windowField);
345
+ // Chunk a BULK pull; keep a short delta as ONE precise `>=` query. Day-granularity windows
346
+ // deliberately re-read the whole boundary day, which is safe but wasteful for a five-minute
347
+ // delta — so a range that fits in a single chunk stays on the exact operator filter unless the
348
+ // operator explicitly asked for a bounded window.
349
+ const resuming = ctx.AfterKeyValue != null && String(ctx.AfterKeyValue).length > 0;
350
+ const windows = plan.length > 1
351
+ || (plan.length >= 1 && (resuming || this.HasExplicitWindowConfig(companyIntegration)))
352
+ ? plan
353
+ : [];
354
+ const batchLimit = ctx.BatchSize && ctx.BatchSize > 0 ? ctx.BatchSize : Number.MAX_SAFE_INTEGER;
355
+ // Prove any newly-learned per-tenant column BEFORE the data reads, scoped by the same filter the
356
+ // first read will use. Nothing new to prove ⇒ no request. This is what keeps an unrecognised label
357
+ // name off the data path entirely: Elevate's allow-list is all-or-nothing, so a speculative name in
358
+ // a data read puts every row of the object behind a guess.
359
+ const firstFilters = windows.length === 0
360
+ ? this.WatermarkFilter(obj, ctx)
361
+ : { [windowField]: { date: [windows[0].From, windows[0].To] } };
362
+ await this.VerifyLearnedFields(auth, companyIntegration, obj, route, columns, firstFilters, warnings);
363
+ const rows = [];
364
+ let nextWindowStart;
365
+ if (windows.length === 0) {
366
+ // No declared date field, no lower bound to chunk from, or a delta that fits one chunk: ONE
367
+ // query, and the completeness tripwire inside RunReportQuery is what proves it actually
368
+ // returned everything.
369
+ const filters = this.WatermarkFilter(obj, ctx);
370
+ rows.push(...await this.RunReportQuery(auth, companyIntegration, obj, route, columns, filters, warnings, ctx));
371
+ }
372
+ else {
373
+ for (let i = 0; i < windows.length; i++) {
374
+ if (rows.length >= batchLimit) {
375
+ nextWindowStart = windows[i].From;
376
+ break;
377
+ }
378
+ rows.push(...await this.RunWindow(auth, companyIntegration, obj, route, columns, windowField, windows[i], warnings, ctx, 0));
379
+ }
380
+ }
381
+ const pkNames = this.PrimaryKeyNames(iofs);
382
+ const records = rows.map(raw => this.ToElevateRecord(this.applyTransformPreservingKeys(raw, obj, iofs), ctx.ObjectName, pkNames, columns, obj));
383
+ const result = {
384
+ Records: records,
385
+ HasMore: nextWindowStart != null,
386
+ Warnings: warnings.length > 0 ? warnings : undefined,
387
+ };
388
+ if (nextWindowStart != null)
389
+ result.NextAfterKeyValue = nextWindowStart;
390
+ // Max-SEEN watermark, and only on a batch that completed without throwing: a mid-iteration
391
+ // failure propagates out of this method, so the watermark is never advanced over a partial read.
392
+ const watermark = this.MaxWatermark(obj, rows);
393
+ if (watermark != null)
394
+ result.NewWatermarkValue = watermark;
395
+ return result;
396
+ }
397
+ // ── REST transport primitives ─────────────────────────────────────────────
398
+ /**
399
+ * Resolves the per-connection credential. Elevate's api_key is a single static per-client string
400
+ * with no authorize/token endpoint, no scopes and no documented expiry, so there is nothing to
401
+ * refresh and the resolved context is cached per CompanyIntegration. The credential is read through
402
+ * the standard `MJ: Credentials` record when the connection carries one, with the connection's own
403
+ * `Configuration` JSON as the fallback — no inline crypto anywhere.
404
+ */
405
+ async Authenticate(companyIntegration, contextUser) {
406
+ const cached = this.authCache.get(companyIntegration.ID);
407
+ if (cached)
408
+ return cached;
409
+ const creds = await this.LoadCredentials(companyIntegration, contextUser);
410
+ const siteUrl = (creds.SiteUrl ?? '').trim().replace(/\/+$/, '');
411
+ if (!siteUrl) {
412
+ throw new Error('[elevate] No Elevate site URL configured. Elevate is deployed PER CLIENT — there is no shared ' +
413
+ 'vendor API host — so the connection must supply "siteUrl" (the client\'s own Elevate site root) ' +
414
+ 'on its credential or Configuration JSON.');
415
+ }
416
+ if (!/^https?:\/\//i.test(siteUrl)) {
417
+ throw new Error(`[elevate] Configured siteUrl "${siteUrl}" is not an absolute http(s) URL.`);
418
+ }
419
+ const apiKey = (creds.ApiKey ?? '').trim();
420
+ if (!apiKey) {
421
+ throw new Error('[elevate] No API key configured. Elevate carries its credential as an `api_key` field in the ' +
422
+ 'BODY of every Report/Registration API request, so no call can be made without it. Supply ' +
423
+ '"apiKey" on the connection credential (issued out-of-band by the client\'s Project Manager).');
424
+ }
425
+ const ctx = {
426
+ SiteUrl: siteUrl,
427
+ ApiKey: apiKey,
428
+ IntegrationID: companyIntegration.IntegrationID,
429
+ };
430
+ this.authCache.set(companyIntegration.ID, ctx);
431
+ return ctx;
432
+ }
433
+ /**
434
+ * Transport headers ONLY. Elevate's credential does NOT travel in a header or a query string on any
435
+ * of its three POST operations — `Configuration.AuthHeaderPattern` is deliberately null and
436
+ * `AuthCredentialParamLocation` is 'body'. Reaching for the generic Bearer/Basic header path for
437
+ * this vendor is wrong, and no credential byte is ever placed here.
438
+ */
439
+ BuildHeaders(_auth) {
440
+ return { 'Content-Type': 'application/json', 'Accept': 'application/json' };
441
+ }
442
+ /**
443
+ * The single wire choke point. It owns four vendor-specific concerns:
444
+ * 1. Injecting `api_key` into the request BODY (never a header, never a query param, never logged).
445
+ * 2. Treating a VENDOR ERROR ENVELOPE as a failure even on a 2xx — the read door has been observed
446
+ * answering `{ error: { message } }` and the write endpoints document `{ error_messages: {...} }`
447
+ * with no status code shown, so a body-blind success check would sync zero rows silently.
448
+ * 3. Honouring 429 (and 503 carrying `Retry-After`) with bounded adaptive backoff.
449
+ * 4. NEVER retrying a 500 — this door returns 500 for CLIENT errors (wrong resource name,
450
+ * non-existent field), and blind-retrying burns the unquantified rate-limit budget replaying a
451
+ * request that can never succeed.
452
+ */
453
+ async MakeHTTPRequest(auth, url, method, headers, body) {
454
+ const ctx = auth;
455
+ const payload = this.WithCredential(ctx, body);
456
+ let attempt = 0;
457
+ for (;;) {
458
+ const response = await this.rawRequest(url, method, headers, payload);
459
+ const classification = this.ClassifyElevateResponse(response.Status, response.Body);
460
+ if (!classification.IsError)
461
+ return response;
462
+ const retryable = classification.Retryable && attempt < MAX_THROTTLE_RETRIES;
463
+ if (!retryable) {
464
+ throw new ElevateAPIError(`[elevate] HTTP ${response.Status} (${classification.Reason}) from ${this.PathOf(url)}` +
465
+ `${classification.UnknownField ? ` — unknown field "${classification.UnknownField}"` : ''}` +
466
+ `: ${this.Redact(ctx, this.VendorMessage(response.Body) ?? 'no vendor message')}`, response.Status, response.Headers, classification);
467
+ }
468
+ attempt++;
469
+ const probe = new ElevateAPIError('throttled', response.Status, response.Headers, classification);
470
+ await this.Sleep(this.ExtractRetryAfterMs(probe) ?? Math.min(MAX_RETRY_AFTER_MS, 1_000 * 2 ** attempt));
471
+ }
472
+ }
473
+ /**
474
+ * Strips the Report API envelope. The shape was OBSERVED by the RealityProbe (key names only):
475
+ * `{ response: { labels: {...}, items: [...], count: N } }`, so the rows are the array at the
476
+ * metadata-declared `ResponseDataKey` = `response.items`. A dotted key is walked, never split on the
477
+ * first segment only — declaring `response.items` and reading `response` would return zero rows.
478
+ */
479
+ NormalizeResponse(rawBody, responseDataKey) {
480
+ const target = responseDataKey ? this.ReadPath(rawBody, responseDataKey.split('.')) : rawBody;
481
+ if (!Array.isArray(target))
482
+ return [];
483
+ return target.filter((r) => r != null && typeof r === 'object' && !Array.isArray(r));
484
+ }
485
+ /**
486
+ * ALWAYS `HasMore: false`. Pagination was PROBED AND DECIDED NEGATIVE at volume: 29,003 unfiltered
487
+ * rows equalled the sum of 15 all-HTTP-200 yearly windows, and none of limit/offset/page/per_page/
488
+ * page_size changed the row count. There is no scheme to extract, and inventing one would silently
489
+ * truncate. Bulk beyond the probed ceiling is bounded by DATE WINDOWS in `FetchChanges`, not pages.
490
+ */
491
+ ExtractPaginationInfo(_rawBody, _paginationType, _currentPage, _currentOffset, _pageSize) {
492
+ return { HasMore: false };
493
+ }
494
+ /** The connection's OWN Elevate site root. There is no vendor host and no fallback default. */
495
+ GetBaseURL(_companyIntegration, auth) {
496
+ return auth.SiteUrl;
497
+ }
498
+ /**
499
+ * Projects each declared dot-path column onto its flat MJ column name (`product.title` lands at
500
+ * `raw.product.title`, the IOF is named `product_title`) while PRESERVING the complete source row:
501
+ * the spread keeps every key the door returned so the framework's custom-column capture can still
502
+ * see a per-tenant column this build never declared.
503
+ */
504
+ TransformRecord(raw, _obj, fields) {
505
+ const columns = this.ReadColumnsFor(fields).filter(c => c.ResponsePath.length > 1);
506
+ if (columns.length === 0)
507
+ return raw;
508
+ const projected = { ...raw };
509
+ for (const col of columns) {
510
+ if (col.Name in projected)
511
+ continue;
512
+ const value = this.ReadPath(raw, col.ResponsePath);
513
+ if (value !== undefined)
514
+ projected[col.Name] = value;
515
+ }
516
+ return projected;
517
+ }
518
+ // ── Write surface (generic slots, one idiosyncratic verb) ─────────────────
519
+ // `CreateRecord` is DELIBERATELY NOT overridden. It stays on the base class's generic
520
+ // per-operation path, which reads `CreateAPIPath` (`/api/registrations`), `CreateMethod` (`POST`),
521
+ // `CreateBodyShape` (`flat`) and `CreateIDLocation` (`body.registration_id`) straight off the
522
+ // IntegrationObject row. The vendor treats the call as an UPSERT keyed on `remote_user_id` (the SSO
523
+ // identity), so a repeat for the same learner/product does not mint a duplicate person. The only
524
+ // vendor-specific part — reading `registration_id` (and `product_url`) out of a DOTTED body
525
+ // location — rides the `ExtractIDFromResponse` hook below, not a re-implemented CreateRecord.
526
+ /**
527
+ * Reads the created record's id from a DOTTED body location. The base helper understands only
528
+ * `body` / `header`; Elevate declares `body.registration_id`, and `registration_id` is the ONLY
529
+ * handle the Cancellation API accepts, so losing it here would make every cancel impossible.
530
+ * `product_url` from the same body is stashed on {@link LastCreatedProductURLs}.
531
+ */
532
+ ExtractIDFromResponse(response, idLocation) {
533
+ if (idLocation && idLocation.startsWith('body.')) {
534
+ const value = this.ReadPath(response.Body, idLocation.slice('body.'.length).split('.'));
535
+ const id = value == null ? undefined : String(value);
536
+ if (id != null) {
537
+ const productURL = this.ReadPath(response.Body, ['product_url']);
538
+ if (typeof productURL === 'string' && productURL.length > 0) {
539
+ if (this.LastCreatedProductURLs.size >= MAX_TRACKED_PRODUCT_URLS) {
540
+ const oldest = this.LastCreatedProductURLs.keys().next();
541
+ if (!oldest.done)
542
+ this.LastCreatedProductURLs.delete(oldest.value);
543
+ }
544
+ this.LastCreatedProductURLs.set(id, productURL);
545
+ }
546
+ }
547
+ return id;
548
+ }
549
+ return super.ExtractIDFromResponse(response, idLocation);
550
+ }
551
+ /** Reads the vendor's message out of either observed envelope: `{error:{message}}` / `{error_messages:{}}`. */
552
+ ExtractErrorMessage(response) {
553
+ return this.VendorMessage(response.Body) ?? super.ExtractErrorMessage(response);
554
+ }
555
+ /**
556
+ * ALWAYS fails, explicitly. No update endpoint is documented for ANY Elevate resource, so there is
557
+ * nothing to call: no IO carries `UpdateAPIPath`. This method exists so the failure is a classified,
558
+ * visible NOT-SUPPORTED rather than a silent no-op — and it must never degrade into a create, which
559
+ * would mint a second registration for the same learner.
560
+ */
561
+ async UpdateRecord(ctx) {
562
+ const message = `[elevate] UPDATE_NOT_SUPPORTED (CONFIGURATION_ERROR): the Elevate API exposes no update ` +
563
+ `endpoint for "${ctx.ObjectName}" (or for any other resource) — only registration create and ` +
564
+ `registration cancel exist. Refusing to fall back to a create, which would mint a duplicate ` +
565
+ `registration. External ID "${ctx.ExternalID}" was left untouched.`;
566
+ return { Success: false, StatusCode: 501, ErrorMessage: message };
567
+ }
568
+ /**
569
+ * OVERRIDDEN for one reason: the id location. Elevate's cancel is a POST whose `registration_id`
570
+ * travels in the BODY (`DeleteIDLocation` = `body.registration_id`), and the base class's generic
571
+ * delete sends NO body at all for a non-path id location — the call would arrive without the id and
572
+ * cancel nothing. Everything else is still read from metadata: the verb comes from `DeleteMethod`
573
+ * (POST, never assumed DELETE) and the path is used EXACTLY as declared — `/registrations/cancel`
574
+ * genuinely has no `/api/` prefix, and "fixing" it would 404.
575
+ */
576
+ async DeleteRecord(ctx) {
577
+ const companyIntegration = ctx.CompanyIntegration;
578
+ const contextUser = ctx.ContextUser;
579
+ const obj = this.GetCachedObject(companyIntegration.IntegrationID, ctx.ObjectName);
580
+ if (!obj.DeleteAPIPath || !obj.DeleteMethod) {
581
+ return {
582
+ Success: false,
583
+ StatusCode: 501,
584
+ ErrorMessage: `[elevate] DELETE_NOT_SUPPORTED (CONFIGURATION_ERROR): "${ctx.ObjectName}" declares no ` +
585
+ `DeleteAPIPath/DeleteMethod. Elevate's only delete-shaped operation is the registration cancel.`,
586
+ };
587
+ }
588
+ const idLocation = obj.DeleteIDLocation ?? 'body';
589
+ if (!idLocation.startsWith('body')) {
590
+ return {
591
+ Success: false,
592
+ StatusCode: 501,
593
+ ErrorMessage: `[elevate] DELETE_NOT_SUPPORTED (CONFIGURATION_ERROR): DeleteIDLocation "${idLocation}" is not a ` +
594
+ `body location; Elevate's cancel carries its id in the request body.`,
595
+ };
596
+ }
597
+ if (!idLocation.includes('.')) {
598
+ return {
599
+ Success: false,
600
+ StatusCode: 501,
601
+ ErrorMessage: `[elevate] DELETE_NOT_SUPPORTED (CONFIGURATION_ERROR): DeleteIDLocation is "${idLocation}" but does ` +
602
+ `not NAME the body key the cancel endpoint expects (metadata declares "body.registration_id"). ` +
603
+ `Refusing to guess a field name — a POST with the wrong key cancels nothing and still returns 200.`,
604
+ };
605
+ }
606
+ const idKey = idLocation.slice(idLocation.indexOf('.') + 1);
607
+ const auth = await this.Authenticate(companyIntegration, contextUser);
608
+ const url = this.JoinURL(this.GetBaseURL(companyIntegration, auth), obj.DeleteAPIPath);
609
+ const body = {};
610
+ body[idKey] = ctx.ExternalID;
611
+ const response = await this.MakeHTTPRequest(auth, url, obj.DeleteMethod, this.BuildHeaders(auth), body);
612
+ if (response.Status >= 200 && response.Status < 300) {
613
+ return { Success: true, StatusCode: response.Status, ExternalID: ctx.ExternalID };
614
+ }
615
+ return {
616
+ Success: false,
617
+ StatusCode: response.Status,
618
+ ErrorMessage: this.ExtractErrorMessage(response) ?? `HTTP ${response.Status} on cancel`,
619
+ };
620
+ }
621
+ // ── Error classification ──────────────────────────────────────────────────
622
+ /**
623
+ * Classifies from the observed error ENVELOPE, not the status alone. Two shapes exist and they are
624
+ * NOT shared between surfaces (`Configuration.ErrorResponseShape.surfaceSplit`): the read door
625
+ * answers `{ error: { message } }` (observed at HTTP 500) and the write endpoints document
626
+ * `{ error_messages: { field: message } }` with NO status code ever shown. A 2xx carrying either is
627
+ * a FAILURE — treating it as a successful empty read is exactly how a sync reports zero rows and
628
+ * green at the same time.
629
+ */
630
+ ClassifyElevateResponse(status, body) {
631
+ const vendorMessage = this.VendorMessage(body);
632
+ const unknownField = this.UnknownFieldFrom(vendorMessage);
633
+ if (status === 429) {
634
+ return { IsError: true, Code: 'RATE_LIMIT_EXCEEDED', Severity: 'Warning', Retryable: true, Reason: 'throttled', UnknownField: null };
635
+ }
636
+ if (status === 503) {
637
+ return { IsError: true, Code: 'NETWORK_TIMEOUT', Severity: 'Warning', Retryable: true, Reason: 'service-unavailable', UnknownField: null };
638
+ }
639
+ if (unknownField != null) {
640
+ return {
641
+ IsError: true, Code: 'CONFIGURATION_ERROR', Severity: 'Critical', Retryable: false,
642
+ Reason: 'unknown-field-in-allow-list', UnknownField: unknownField,
643
+ };
644
+ }
645
+ if (vendorMessage != null && /wrong resource name/i.test(vendorMessage)) {
646
+ return {
647
+ IsError: true, Code: 'CONFIGURATION_ERROR', Severity: 'Critical', Retryable: false,
648
+ Reason: 'unknown-resource', UnknownField: null,
649
+ };
650
+ }
651
+ if (vendorMessage != null) {
652
+ // A vendor error envelope on ANY status, 2xx included. NEVER retryable: this door answers
653
+ // HTTP 500 for client-side mistakes, so a retry can only burn the rate-limit budget.
654
+ const classified = ClassifyError(new Error(vendorMessage));
655
+ return {
656
+ IsError: true,
657
+ Code: classified.Code === 'UNKNOWN_ERROR' ? 'CONNECTOR_ERROR' : classified.Code,
658
+ Severity: classified.Severity, Retryable: false,
659
+ Reason: status >= 200 && status < 300 ? 'vendor-error-in-2xx' : 'vendor-error-envelope',
660
+ UnknownField: null,
661
+ };
662
+ }
663
+ if (status < 200 || status >= 300) {
664
+ return {
665
+ IsError: true, Code: status === 401 || status === 403 ? 'CONFIGURATION_ERROR' : 'CONNECTOR_ERROR',
666
+ Severity: 'Critical', Retryable: false, Reason: `http-${status}`, UnknownField: null,
667
+ };
668
+ }
669
+ return { IsError: false, Code: 'UNKNOWN_ERROR', Severity: 'Info', Retryable: false, Reason: 'ok', UnknownField: null };
670
+ }
671
+ // ── Transport seam (test subclasses override THIS, not MakeHTTPRequest) ───
672
+ /** The raw HTTP call. Isolated so a mocked subclass can capture the wire without losing the behaviour above. */
673
+ async rawRequest(url, method, headers, body) {
674
+ const response = await fetch(url, {
675
+ method,
676
+ headers,
677
+ body: body !== undefined ? JSON.stringify(body) : undefined,
678
+ redirect: 'manual',
679
+ });
680
+ const respHeaders = {};
681
+ response.headers.forEach((v, k) => { respHeaders[k.toLowerCase()] = v; });
682
+ const text = await response.text();
683
+ let parsed = null;
684
+ if (text.length > 0) {
685
+ try {
686
+ parsed = JSON.parse(text);
687
+ }
688
+ catch {
689
+ parsed = text;
690
+ }
691
+ }
692
+ return { Status: response.status, Body: parsed, Headers: respHeaders };
693
+ }
694
+ // ── Metadata accessors (seams; test subclasses override these) ────────────
695
+ /** All ACTIVE IntegrationObjects for this integration; `[]` when the engine cache is unavailable. */
696
+ getCachedObjects(integrationID) {
697
+ try {
698
+ return IntegrationEngineBase.Instance.GetActiveIntegrationObjects(integrationID);
699
+ }
700
+ catch {
701
+ return [];
702
+ }
703
+ }
704
+ /** The IntegrationID for this integration, or null when the engine cache is unavailable. */
705
+ tryGetIntegrationID() {
706
+ try {
707
+ return IntegrationEngineBase.Instance.GetIntegrationByName(this.IntegrationName)?.ID ?? null;
708
+ }
709
+ catch {
710
+ return null;
711
+ }
712
+ }
713
+ // ── Read-path internals ───────────────────────────────────────────────────
714
+ /**
715
+ * Runs ONE report query and returns its rows. Two safety behaviours ride here because both are
716
+ * per-query facts, not per-object ones:
717
+ * • THE COMPLETENESS TRIPWIRE — `response.count` is the door's own total for the query; when it
718
+ * exceeds `response.items.length` the read was silently truncated, which is the only protection
719
+ * that survives above the 29,003 rows the probe actually measured.
720
+ * • THE ALLOW-LIST REPAIR — a SAFETY NET for DECLARED columns (a wrong `wireSelector` in metadata):
721
+ * the door rejects the WHOLE query for one unrecognised column and names the offender, so that name
722
+ * is dropped, remembered and the query retried. Runtime-discovered names never rely on it — they
723
+ * are proven out of band by {@link VerifyLearnedFields} before they may enter a data read — and the
724
+ * repair refuses to act when the named column was not in the request it just sent, because dropping
725
+ * it changes nothing and the retry would replay an identical, already-failed call.
726
+ */
727
+ async RunReportQuery(auth, companyIntegration, obj, route, columns, filters, warnings, ctx) {
728
+ const key = this.CacheKey(companyIntegration, obj.Name);
729
+ const url = this.JoinURL(this.GetBaseURL(companyIntegration, auth), route.Door);
730
+ const headers = this.BuildHeaders(auth);
731
+ for (let repair = 0; repair <= MAX_SELECTOR_REPAIRS; repair++) {
732
+ const selectors = this.SelectorsFor(companyIntegration, obj, columns, ctx);
733
+ if (selectors.length === 0) {
734
+ throw new Error(`[elevate] No read-surface columns are declared for "${obj.Name}", so no \`fields\` allow-list ` +
735
+ 'can be built. Elevate returns ONLY the columns a request names, so a fieldless query is useless.');
736
+ }
737
+ const body = this.BuildEnvelope(companyIntegration, route.Resource, selectors, filters);
738
+ try {
739
+ const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, body);
740
+ const rows = this.NormalizeResponse(response.Body, route.DataKey);
741
+ this.LearnLabels(key, response.Body, route, rows);
742
+ this.CheckCompleteness(obj, response.Body, route, rows.length, filters, warnings);
743
+ return rows;
744
+ }
745
+ catch (err) {
746
+ // A catch binding is `unknown` under a `strict` tsconfig (the Open App package compiles with
747
+ // `useUnknownInCatchVariables`), so narrow ONCE, here, on the only error shape this repair
748
+ // can act on. Anything else — a transport failure, an abort — is rethrown untouched, which is
749
+ // exactly what the previous `instanceof ? ... : null` expression did; this form just gives the
750
+ // rest of the handler a typed `err` instead of a cast at every use.
751
+ if (!(err instanceof ElevateAPIError))
752
+ throw err;
753
+ const rejected = err.Classification.UnknownField;
754
+ if (rejected == null || repair === MAX_SELECTOR_REPAIRS)
755
+ throw err;
756
+ if (!selectors.some(s => s.toLowerCase() === rejected.toLowerCase())) {
757
+ // The door named a column this request did NOT ask for. The all-or-nothing repair has
758
+ // nothing to act on: dropping the name changes no byte of the envelope, so retrying can
759
+ // only replay an identical, already-failed request against a rate-limited door until the
760
+ // repair budget runs out — and the eventual error still points at the field selector,
761
+ // which is NOT where the fault is. Fail immediately, and say which request was actually
762
+ // sent so the next reader looks at the routing rather than the columns.
763
+ throw new ElevateAPIError(`${err.message} — but "${rejected}" was NOT in this request's \`fields\` allow-list ` +
764
+ `(sent: ${selectors.slice(0, 12).join(', ')}${selectors.length > 12 ? ', …' : ''}), so the ` +
765
+ 'all-or-nothing repair cannot act on it. The rejection does not describe the request ' +
766
+ `that was sent for "${obj.Name}" — check the door/resource routing, not the selector.`, err.Status, err.Headers, err.Classification);
767
+ }
768
+ this.RememberRejected(key, rejected);
769
+ this.WarnOnce(`rejected-field:${key}:${rejected}`, `[elevate] The door rejected column "${rejected}" on "${obj.Name}" and Elevate's \`fields\` ` +
770
+ 'allow-list is ALL-OR-NOTHING, so the whole query failed. Dropping that column for this ' +
771
+ 'connection and retrying. If it is a DECLARED column, the metadata wire selector is wrong.');
772
+ warnings.push({
773
+ Code: 'FIELD_REJECTED',
774
+ Message: `Elevate rejected column "${rejected}" on "${obj.Name}"; it was dropped from the read selector.`,
775
+ Data: { objectName: obj.Name, field: rejected },
776
+ });
777
+ }
778
+ }
779
+ throw new Error(`[elevate] Field-selector repair for "${obj.Name}" did not converge.`);
780
+ }
781
+ /**
782
+ * Runs one date window, halving it when the door reports truncation. That is the ADAPTIVE sizing the
783
+ * probe's evidence calls for: it does not assume a chunk size is small enough, it verifies each chunk
784
+ * against the door's own count and splits until every chunk is provably complete.
785
+ */
786
+ async RunWindow(auth, companyIntegration, obj, route, columns, windowField, window, warnings, ctx, depth) {
787
+ const before = warnings.length;
788
+ const filters = {};
789
+ filters[windowField] = { date: [window.From, window.To] };
790
+ const rows = await this.RunReportQuery(auth, companyIntegration, obj, route, columns, filters, warnings, ctx);
791
+ const truncated = warnings.slice(before).some(w => w.Code === 'INCOMPLETE_READ');
792
+ if (!truncated || depth >= MAX_WINDOW_SPLIT_DEPTH)
793
+ return rows;
794
+ const halves = this.SplitWindow(window);
795
+ if (halves == null)
796
+ return rows; // one-day window: nothing left to split — the warning stands.
797
+ warnings.length = before; // the parent window is superseded by its halves.
798
+ const out = [];
799
+ for (const half of halves) {
800
+ out.push(...await this.RunWindow(auth, companyIntegration, obj, route, columns, windowField, half, warnings, ctx, depth + 1));
801
+ }
802
+ return out;
803
+ }
804
+ /**
805
+ * Builds the request envelope. Key order is deliberate and stable — `resource` sits next to
806
+ * `format` so a captured request is self-describing. `api_key` is NOT added here; it is injected at
807
+ * the transport choke point so no caller can accidentally log or persist an envelope carrying it.
808
+ */
809
+ BuildEnvelope(companyIntegration, resource, selectors, filters) {
810
+ const fields = {};
811
+ for (const s of selectors)
812
+ fields[s] = true;
813
+ const envelope = {
814
+ format: this.ResolveFormat(companyIntegration, selectors),
815
+ resource,
816
+ fields,
817
+ };
818
+ if (filters != null && Object.keys(filters).length > 0)
819
+ envelope.filters = filters;
820
+ return envelope;
821
+ }
822
+ /**
823
+ * json unless the connection explicitly asks for csv AND every selector is flat. The vendor is
824
+ * explicit that CSV "is limited to only the values in the top-most layer of the API response", so a
825
+ * dot-path selection silently loses its columns in csv — the request is forced back to json and the
826
+ * downgrade is announced rather than quietly honoured.
827
+ */
828
+ ResolveFormat(companyIntegration, selectors) {
829
+ const requested = this.ConfigString(companyIntegration, ['elevateFormat', 'format']);
830
+ if (requested?.toLowerCase() !== 'csv')
831
+ return 'json';
832
+ const nested = selectors.filter(s => s.includes('.'));
833
+ if (nested.length === 0)
834
+ return 'csv';
835
+ this.WarnOnce('csv-refused', `[elevate] This connection asked for format=csv, but the read selector contains dot-path ` +
836
+ `sub-resource columns (${nested.slice(0, 3).join(', ')}${nested.length > 3 ? ', …' : ''}). CSV is flat ` +
837
+ 'and would silently drop every column below the top layer, so the request is being sent as json.');
838
+ return 'json';
839
+ }
840
+ /**
841
+ * The `fields` allow-list for one DATA read: the DECLARED read-surface wire selectors UNIONED with the
842
+ * per-tenant column names this connection's door has PROVEN it accepts (see {@link VerifyLearnedFields}),
843
+ * minus anything the door has already rejected. A build-time-only list would truncate every record to
844
+ * what this build thought to ask for; the union is what makes a site's configured custom/profile
845
+ * columns reachable.
846
+ *
847
+ * The union is over VERIFIED names, never over freshly-learned ones. Elevate's allow-list is
848
+ * ALL-OR-NOTHING — one unrecognised name fails the WHOLE query — so folding an unproven label name
849
+ * into a data read makes every row of that object hostage to a guess: the read has to fail at least
850
+ * once, and it zeroes the object outright if the door's message does not NAME the offender (the
851
+ * repair below can only act on a named column). Unproven names are therefore proven OUT OF BAND
852
+ * first; a rejection there costs one probe and never a row.
853
+ * `FetchContext.RequestedSourceFields`, when the engine supplies it, narrows the union to the columns
854
+ * actually mapped.
855
+ */
856
+ SelectorsFor(companyIntegration, obj, columns, ctx) {
857
+ const key = this.CacheKey(companyIntegration, obj.Name);
858
+ const rejected = this.rejectedFieldNames.get(key) ?? new Set();
859
+ const wanted = ctx?.RequestedSourceFields && ctx.RequestedSourceFields.length > 0
860
+ ? new Set(ctx.RequestedSourceFields.map(f => f.toLowerCase()))
861
+ : null;
862
+ const out = new Set();
863
+ for (const col of columns) {
864
+ if (rejected.has(col.WireSelector))
865
+ continue;
866
+ if (wanted && !wanted.has(col.Name.toLowerCase()) && !wanted.has(col.WireSelector.toLowerCase()))
867
+ continue;
868
+ out.add(col.WireSelector);
869
+ }
870
+ const declaredSelectors = new Set(columns.map(c => c.WireSelector));
871
+ const declaredNames = new Set(columns.map(c => c.Name.toLowerCase()));
872
+ for (const name of this.SortedNames(this.verifiedFieldNames.get(key))) {
873
+ if (rejected.has(name) || declaredSelectors.has(name) || declaredNames.has(name.toLowerCase()))
874
+ continue;
875
+ if (wanted && !wanted.has(name.toLowerCase()))
876
+ continue;
877
+ out.add(name);
878
+ }
879
+ return [...out];
880
+ }
881
+ /**
882
+ * Proves — OUT OF BAND, before any data read — which of the names runtime discovery has learned for
883
+ * this connection+object the door will actually accept in a `fields` allow-list. This is the whole
884
+ * reason a learned label can no longer zero an object's sync:
885
+ *
886
+ * • the DATA read only ever asks for DECLARED columns ∪ names proven here, so an unrecognised
887
+ * label can never fail it — named in the door's message or not;
888
+ * • a rejection costs exactly this probe (never a row), the offending name is remembered as
889
+ * rejected for the connection and is never asked for again;
890
+ * • a probe that fails for ANY OTHER reason leaves the names UNVERIFIED rather than rejected —
891
+ * absence of proof is not proof of absence, and the next sync re-attempts them.
892
+ *
893
+ * Zero-cost when there is nothing new to prove (the overwhelmingly common case): with no unproven
894
+ * name the method makes no request at all. The probe is scoped by the SAME filter the imminent read
895
+ * uses, so proving a column on a watermarked object does not drag the whole resource across the wire.
896
+ */
897
+ async VerifyLearnedFields(auth, companyIntegration, obj, route, columns, filters, warnings) {
898
+ const key = this.CacheKey(companyIntegration, obj.Name);
899
+ let candidates = this.PendingLearnedFields(key, columns);
900
+ if (candidates.length === 0)
901
+ return;
902
+ const url = this.JoinURL(this.GetBaseURL(companyIntegration, auth), route.Door);
903
+ const headers = this.BuildHeaders(auth);
904
+ for (let repair = 0; repair <= MAX_SELECTOR_REPAIRS && candidates.length > 0; repair++) {
905
+ const body = this.BuildEnvelope(companyIntegration, route.Resource, candidates, filters);
906
+ try {
907
+ const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, body);
908
+ this.LearnLabels(key, response.Body, route, this.NormalizeResponse(response.Body, route.DataKey));
909
+ this.MarkVerified(key, candidates);
910
+ return;
911
+ }
912
+ catch (err) {
913
+ const named = err instanceof ElevateAPIError ? err.Classification.UnknownField : null;
914
+ if (named == null || !candidates.some(c => c.toLowerCase() === named.toLowerCase())) {
915
+ this.WarnOnce(`verify-failed:${key}`, `[elevate] Could not prove runtime-discovered column(s) ${candidates.join(', ')} on ` +
916
+ `"${obj.Name}" (${this.SafeMessage(err)}). They stay UNVERIFIED — not rejected — and are ` +
917
+ 'left OUT of the read selector for now, so the object still syncs on its declared ' +
918
+ 'columns. The next sync re-attempts them.');
919
+ return;
920
+ }
921
+ this.RememberRejected(key, named);
922
+ this.WarnOnce(`rejected-field:${key}:${named}`, `[elevate] The door rejected runtime-discovered column "${named}" on "${obj.Name}". It was ` +
923
+ 'refused during OUT-OF-BAND verification, so no data read ever carried it and no row was ' +
924
+ 'lost. It will not be asked for again on this connection.');
925
+ warnings.push({
926
+ Code: 'FIELD_REJECTED',
927
+ Message: `Elevate rejected runtime-discovered column "${named}" on "${obj.Name}"; it was dropped before any data read.`,
928
+ Data: { objectName: obj.Name, field: named, phase: 'verification' },
929
+ });
930
+ candidates = candidates.filter(c => c.toLowerCase() !== named.toLowerCase());
931
+ }
932
+ }
933
+ }
934
+ /** Learned names not yet proven, not already rejected, and not already covered by a declared column. */
935
+ PendingLearnedFields(key, columns) {
936
+ const learned = this.discoveredFieldNames.get(key);
937
+ if (!learned || learned.size === 0)
938
+ return [];
939
+ const rejected = this.rejectedFieldNames.get(key) ?? new Set();
940
+ const verified = this.verifiedFieldNames.get(key) ?? new Set();
941
+ const declaredSelectors = new Set(columns.map(c => c.WireSelector));
942
+ const declaredNames = new Set(columns.map(c => c.Name.toLowerCase()));
943
+ return this.SortedNames(learned).filter(name => !rejected.has(name)
944
+ && !verified.has(name)
945
+ && !declaredSelectors.has(name)
946
+ && !declaredNames.has(name.toLowerCase()));
947
+ }
948
+ /** Records the learned names this connection's door answered a read for. */
949
+ MarkVerified(key, names) {
950
+ const set = this.verifiedFieldNames.get(key) ?? new Set();
951
+ for (const name of names)
952
+ set.add(name);
953
+ this.verifiedFieldNames.set(key, set);
954
+ }
955
+ /**
956
+ * Records the per-resource field dictionary the door returns on EVERY call (`response.labels`) as
957
+ * runtime-discovered column names for this connection, plus one sampled value each for type
958
+ * inference. This is discovery from a real runtime surface, not a build-time sample: nothing here is
959
+ * written back to the declared metadata.
960
+ */
961
+ LearnLabels(key, rawBody, route, rows) {
962
+ const labels = this.ReadPath(rawBody, route.LabelsKey.split('.'));
963
+ if (labels == null || typeof labels !== 'object' || Array.isArray(labels))
964
+ return;
965
+ const known = this.discoveredFieldNames.get(key) ?? new Set();
966
+ const samples = this.discoveredSamples.get(key) ?? new Map();
967
+ for (const name of Object.keys(labels)) {
968
+ known.add(name);
969
+ if (!samples.has(name)) {
970
+ const observed = rows.find(r => r[name] != null);
971
+ if (observed)
972
+ samples.set(name, observed[name]);
973
+ }
974
+ }
975
+ this.discoveredFieldNames.set(key, known);
976
+ this.discoveredSamples.set(key, samples);
977
+ }
978
+ /** Remembers a column this connection's door refused, so it is never requested for that object again. */
979
+ RememberRejected(key, field) {
980
+ const set = this.rejectedFieldNames.get(key) ?? new Set();
981
+ set.add(field);
982
+ this.rejectedFieldNames.set(key, set);
983
+ }
984
+ /**
985
+ * The silent-truncation tripwire. `response.count` is the door's OWN total for the query; when it
986
+ * disagrees with the number of rows actually returned, the read was capped. Raised as a FetchWarning
987
+ * so the engine surfaces it in the structured run artifact instead of it being a swallowed console line.
988
+ */
989
+ CheckCompleteness(obj, rawBody, route, returned, filters, warnings) {
990
+ const reported = this.ReadPath(rawBody, route.CountKey.split('.'));
991
+ if (typeof reported !== 'number' || !Number.isFinite(reported))
992
+ return;
993
+ if (reported <= returned)
994
+ return;
995
+ warnings.push({
996
+ Code: 'INCOMPLETE_READ',
997
+ Message: `Elevate reported ${reported} row(s) for "${obj.Name}" but returned ${returned}. The door has no ` +
998
+ 'pagination control (probed negative), so the query must be narrowed by a date window — set ' +
999
+ '"elevateWindowStart"/"elevateWindowDays" on the connection Configuration if this object has no ' +
1000
+ 'watermark to chunk from.',
1001
+ Data: { objectName: obj.Name, reportedCount: reported, returnedCount: returned, filtered: filters != null },
1002
+ });
1003
+ }
1004
+ // ── Window planning ───────────────────────────────────────────────────────
1005
+ /**
1006
+ * The date column a bulk pull is chunked on — strictly the object's DECLARED
1007
+ * `IncrementalWatermarkField`, and nothing else. For productRegistration that is `modified_at`: the
1008
+ * probe-proven UPDATE watermark, deliberately not `transaction_at`, which is insert time and cannot
1009
+ * see an edit. `null` for every other object, and then NO window is ever synthesised.
1010
+ *
1011
+ * A date-shaped column is NOT enough to justify a filter here. `EarnedCredit.updated_at` looks like a
1012
+ * watermark and the metadata explicitly WITHHOLDS incremental capability for it: filters-envelope
1013
+ * reachability for that resource is unproven, so chunking on it would apply an unevidenced filter and
1014
+ * would silently drop every row whose column is null. Honour what the probe proved; do not re-derive it.
1015
+ */
1016
+ WindowFieldFor(obj) {
1017
+ return obj.IncrementalWatermarkField ?? null;
1018
+ }
1019
+ /**
1020
+ * Consecutive, NON-OVERLAPPING windows over `[start, end]`. The start is the sync watermark when the
1021
+ * engine supplied one (so a delta pass re-reads only what changed), the resume cursor when a prior
1022
+ * batch stopped mid-plan, or the connection's declared `elevateWindowStart`. With no lower bound at
1023
+ * all the plan is EMPTY and the object is read in one query — verified by the completeness tripwire
1024
+ * rather than assumed complete.
1025
+ */
1026
+ BuildWindowPlan(companyIntegration, ctx, windowField) {
1027
+ if (windowField == null)
1028
+ return [];
1029
+ const resume = this.ToDayString(ctx.AfterKeyValue ?? null);
1030
+ const watermark = this.ToDayString(ctx.WatermarkValue);
1031
+ const configured = this.ToDayString(this.ConfigString(companyIntegration, ['elevateWindowStart']));
1032
+ const start = resume ?? watermark ?? configured;
1033
+ if (start == null)
1034
+ return [];
1035
+ const end = this.ToDayString(this.ConfigString(companyIntegration, ['elevateWindowEnd'])) ?? this.Today();
1036
+ if (end < start)
1037
+ return [];
1038
+ const days = this.ConfigNumber(companyIntegration, ['elevateWindowDays']) ?? DEFAULT_WINDOW_DAYS;
1039
+ const span = Number.isFinite(days) && days >= 1 ? Math.floor(days) : DEFAULT_WINDOW_DAYS;
1040
+ const windows = [];
1041
+ let cursor = start;
1042
+ while (cursor <= end && windows.length < 4_000) {
1043
+ const to = this.MinDay(this.AddDays(cursor, span - 1), end);
1044
+ windows.push({ From: cursor, To: to });
1045
+ cursor = this.AddDays(to, 1);
1046
+ }
1047
+ return windows;
1048
+ }
1049
+ /** Halves a window, or null when it is already a single day and cannot be narrowed further. */
1050
+ SplitWindow(window) {
1051
+ const spanDays = this.DayDiff(window.From, window.To);
1052
+ if (spanDays < 1)
1053
+ return null;
1054
+ const mid = this.AddDays(window.From, Math.floor(spanDays / 2));
1055
+ return [{ From: window.From, To: mid }, { From: this.AddDays(mid, 1), To: window.To }];
1056
+ }
1057
+ /**
1058
+ * The delta filter for an UNCHUNKED incremental read. Only ever built from the object's own declared
1059
+ * watermark; an object whose metadata declares none runs a FULL SCAN, and no delta path is invented
1060
+ * for it.
1061
+ */
1062
+ WatermarkFilter(obj, ctx) {
1063
+ if (!obj.SupportsIncrementalSync || !obj.IncrementalWatermarkField)
1064
+ return null;
1065
+ if (ctx.WatermarkValue == null || ctx.WatermarkValue.length === 0)
1066
+ return null;
1067
+ const filters = {};
1068
+ filters[obj.IncrementalWatermarkField] = { '>=': ctx.WatermarkValue };
1069
+ return filters;
1070
+ }
1071
+ /** Whether the connection explicitly asked for a bounded/chunked pull rather than the default. */
1072
+ HasExplicitWindowConfig(companyIntegration) {
1073
+ const cfg = this.ParseJSONObject(companyIntegration.Configuration);
1074
+ if (!cfg)
1075
+ return false;
1076
+ return ['elevateWindowStart', 'elevateWindowEnd', 'elevateWindowDays'].some(k => cfg[k] != null);
1077
+ }
1078
+ /** Max-SEEN watermark across the batch (never "most recent row"), or null when the object has none. */
1079
+ MaxWatermark(obj, rows) {
1080
+ if (!obj.SupportsIncrementalSync || !obj.IncrementalWatermarkField)
1081
+ return null;
1082
+ const field = obj.IncrementalWatermarkField;
1083
+ let max = null;
1084
+ for (const row of rows) {
1085
+ const value = row[field];
1086
+ if (value == null)
1087
+ continue;
1088
+ const asString = value instanceof Date ? value.toISOString() : String(value);
1089
+ if (asString.length === 0)
1090
+ continue;
1091
+ if (max == null || asString > max)
1092
+ max = asString;
1093
+ }
1094
+ return max;
1095
+ }
1096
+ // ── Record assembly ───────────────────────────────────────────────────────
1097
+ /**
1098
+ * Builds one ExternalRecord. Identity is STABLE ACROSS PASSES by construction:
1099
+ * • when the object's DECLARED primary key is fully populated, the ExternalID is that key;
1100
+ * • otherwise — every Elevate object except Product, whose keys the RealityProbe demoted or
1101
+ * falsified — the identity is a content hash over the DECLARED READ PROJECTION only, never over
1102
+ * the whole raw row. That distinction is the point: hashing the raw row makes identity a
1103
+ * function of any volatile or per-tenant byte the door happens to add, which is the drift class
1104
+ * the two-pass idempotency rung exists to catch.
1105
+ * `Fields` still carries the COMPLETE source row (plus the flattened projections) so the framework's
1106
+ * custom-column capture sees everything the door returned.
1107
+ */
1108
+ ToElevateRecord(raw, objectType, pkFieldNames, columns, obj) {
1109
+ const allPkPresent = pkFieldNames.length > 0
1110
+ && pkFieldNames.every(name => raw[name] != null && serializeKeyValue(raw[name]).length > 0);
1111
+ const composite = pkFieldNames.map(name => serializeKeyValue(raw[name])).join('|');
1112
+ const resolvedID = allPkPresent ? composite : computeContentHash(this.IdentityBasis(raw, columns));
1113
+ let fields = raw;
1114
+ if (!allPkPresent && pkFieldNames.length === 1
1115
+ && (raw[pkFieldNames[0]] == null || serializeKeyValue(raw[pkFieldNames[0]]).length === 0)) {
1116
+ fields = { ...raw };
1117
+ fields[pkFieldNames[0]] = resolvedID;
1118
+ }
1119
+ const record = { ExternalID: resolvedID, ObjectType: objectType, Fields: fields };
1120
+ const watermark = obj.IncrementalWatermarkField ? raw[obj.IncrementalWatermarkField] : null;
1121
+ if (watermark != null) {
1122
+ const when = new Date(String(watermark));
1123
+ if (!Number.isNaN(when.getTime()))
1124
+ record.ModifiedAt = when;
1125
+ }
1126
+ return record;
1127
+ }
1128
+ /** The stable projection a keyless record's identity hashes over: declared columns only, by MJ name. */
1129
+ IdentityBasis(raw, columns) {
1130
+ const basis = {};
1131
+ for (const col of columns) {
1132
+ const value = col.Name in raw ? raw[col.Name] : this.ReadPath(raw, col.ResponsePath);
1133
+ if (value !== undefined)
1134
+ basis[col.Name] = value;
1135
+ }
1136
+ return Object.keys(basis).length > 0 ? basis : raw;
1137
+ }
1138
+ /** Declared PK names in Sequence order, mirroring the base class's `['ID']` synthetic fallback. */
1139
+ PrimaryKeyNames(fields) {
1140
+ const pk = fields.filter(f => f.IsPrimaryKey).sort((a, b) => a.Sequence - b.Sequence).map(f => f.Name);
1141
+ return pk.length > 0 ? pk : ['ID'];
1142
+ }
1143
+ // ── Metadata routing helpers ──────────────────────────────────────────────
1144
+ /** The read route for one object, entirely from metadata. Throws rather than guessing a wire value. */
1145
+ ReadRouteFor(obj) {
1146
+ const cfg = this.ObjectConfig(obj);
1147
+ const readContract = this.AsObject(cfg?.readContract);
1148
+ const resource = this.FirstString(cfg, ['resourceWireValue'])
1149
+ ?? this.AccessPathResource(cfg);
1150
+ if (!resource) {
1151
+ throw new Error(`[elevate] IntegrationObject "${obj.Name}" declares no Configuration.resourceWireValue. Elevate ` +
1152
+ 'selects the object with a BODY field, so without the wire value there is no query to send — and ' +
1153
+ 'guessing it in code is exactly how the vendor\'s own prose spelling "accountCode" (rejected with ' +
1154
+ 'HTTP 500) would get shipped instead of the proven "accountingCode".');
1155
+ }
1156
+ return {
1157
+ Door: obj.APIPath,
1158
+ Resource: resource,
1159
+ DataKey: obj.ResponseDataKey,
1160
+ CountKey: this.FirstString(readContract, ['responseCountKey']) ?? 'response.count',
1161
+ LabelsKey: this.FirstString(readContract, ['responseLabelsKey']) ?? 'response.labels',
1162
+ };
1163
+ }
1164
+ /** Fallback resource resolution: the depth-0 access path's own body selector. Still metadata, not code. */
1165
+ AccessPathResource(cfg) {
1166
+ const paths = cfg?.accessPaths;
1167
+ if (!Array.isArray(paths))
1168
+ return undefined;
1169
+ for (const entry of paths) {
1170
+ const path = this.AsObject(entry);
1171
+ if (path == null || (typeof path.depth === 'number' && path.depth !== 0))
1172
+ continue;
1173
+ const body = this.AsObject(path.body);
1174
+ const resource = body ? body.resource : undefined;
1175
+ if (typeof resource === 'string' && resource.length > 0)
1176
+ return resource;
1177
+ }
1178
+ return undefined;
1179
+ }
1180
+ /**
1181
+ * The object's READ-surface columns. A field is excluded when the metadata marks it write-only or
1182
+ * explicitly excludes it from the read selector — `registration_id` is exactly that case: the probe
1183
+ * FALSIFIED it as a read column (`Field registration_id doesn't exist`) while it remains the only
1184
+ * handle the cancel API accepts. Sending it would fail the WHOLE query for the object.
1185
+ */
1186
+ ReadColumnsFor(fields) {
1187
+ const out = [];
1188
+ for (const f of fields) {
1189
+ const cfg = this.ParseJSONObject(f.Configuration);
1190
+ if (this.FirstString(cfg, ['surface']) === 'write-only')
1191
+ continue;
1192
+ if (cfg?.excludeFromReadFieldSelector === true)
1193
+ continue;
1194
+ const wire = this.FirstString(cfg, ['wireSelector']) ?? f.Name;
1195
+ const path = Array.isArray(cfg?.responsePath)
1196
+ ? cfg.responsePath.filter((p) => typeof p === 'string')
1197
+ : wire.split('.');
1198
+ out.push({ Name: f.Name, WireSelector: wire, ResponsePath: path.length > 0 ? path : [f.Name] });
1199
+ }
1200
+ return out;
1201
+ }
1202
+ /** Parsed `Configuration` JSON for one IntegrationObject. */
1203
+ ObjectConfig(obj) {
1204
+ return this.ParseJSONObject(obj.Configuration);
1205
+ }
1206
+ // ── Runtime validation probes ─────────────────────────────────────────────
1207
+ /**
1208
+ * Probes one declared resource against THIS connection with the cheapest possible query. Accepted ⇒
1209
+ * present. A rejection is remembered and warned about but NEVER removes the object — with no describe
1210
+ * endpoint, absence proves nothing, and deactivating on a thin result is tenant-visible data loss.
1211
+ */
1212
+ async ValidateResource(companyIntegration, contextUser, objectName) {
1213
+ const key = this.CacheKey(companyIntegration, objectName);
1214
+ const cached = this.validatedResources.get(key);
1215
+ if (cached != null)
1216
+ return cached;
1217
+ let accepted = false;
1218
+ try {
1219
+ const obj = this.GetCachedObject(companyIntegration.IntegrationID, objectName);
1220
+ const iofs = this.GetCachedFields(obj.ID);
1221
+ const columns = this.ReadColumnsFor(iofs);
1222
+ if (columns.length === 0) {
1223
+ this.WarnOnce(`no-read-columns:${objectName}`, `[elevate] "${objectName}" declares no read-surface column, so no probe query can be formed. ` +
1224
+ 'The object is still reported as present — discovery here is additive, never deactivating.');
1225
+ this.validatedResources.set(key, false);
1226
+ return false;
1227
+ }
1228
+ const auth = await this.Authenticate(companyIntegration, contextUser);
1229
+ const route = this.ReadRouteFor(obj);
1230
+ const url = this.JoinURL(this.GetBaseURL(companyIntegration, auth), route.Door);
1231
+ const probeFields = {};
1232
+ probeFields[columns[0].WireSelector] = true;
1233
+ const response = await this.MakeHTTPRequest(auth, url, 'POST', this.BuildHeaders(auth), {
1234
+ format: 'json', resource: route.Resource, fields: probeFields,
1235
+ });
1236
+ this.LearnLabels(key, response.Body, route, this.NormalizeResponse(response.Body, route.DataKey));
1237
+ accepted = true;
1238
+ }
1239
+ catch (err) {
1240
+ this.WarnOnce(`probe-failed:${objectName}`, `[elevate] Declared resource "${objectName}" did not accept a minimal probe query on this ` +
1241
+ `connection (${this.SafeMessage(err)}). It is STILL reported by discovery: Elevate publishes no ` +
1242
+ 'describe endpoint, so a rejection may be a per-tenant permission or a transient fault, and ' +
1243
+ 'deactivating on it would delete real metadata.');
1244
+ }
1245
+ this.validatedResources.set(key, accepted);
1246
+ return accepted;
1247
+ }
1248
+ /** Runs one read so the door's `response.labels` dictionary can be harvested for this connection. */
1249
+ async LearnFieldsFromSource(companyIntegration, contextUser, objectName) {
1250
+ try {
1251
+ await this.ValidateResource(companyIntegration, contextUser, objectName);
1252
+ }
1253
+ catch {
1254
+ /* ValidateResource already warned; the declared floor stands. */
1255
+ }
1256
+ }
1257
+ /** An `ExternalFieldSchema` for a column only runtime discovery has seen. Types inferred, never asserted. */
1258
+ SchemaForDiscoveredField(name, sample) {
1259
+ return {
1260
+ Name: name,
1261
+ Label: name,
1262
+ Description: 'Discovered at runtime from the Elevate Report API `response.labels` dictionary for this connection.',
1263
+ DataType: this.InferType(sample),
1264
+ IsRequired: false,
1265
+ AllowsNull: true,
1266
+ IsUniqueKey: false,
1267
+ IsReadOnly: true,
1268
+ IsPrimaryKey: false,
1269
+ IsForeignKey: false,
1270
+ };
1271
+ }
1272
+ /** Conservative runtime type inference from one observed value. Unknown ⇒ String, never a guessed width. */
1273
+ InferType(sample) {
1274
+ if (typeof sample === 'boolean')
1275
+ return 'Boolean';
1276
+ if (typeof sample === 'number')
1277
+ return Number.isInteger(sample) ? 'Integer' : 'Decimal';
1278
+ if (typeof sample === 'string' && /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2})/.test(sample))
1279
+ return 'Datetime';
1280
+ return 'String';
1281
+ }
1282
+ // ── Credential resolution ─────────────────────────────────────────────────
1283
+ /** Credential record first, connection Configuration second. No inline crypto; nothing is logged. */
1284
+ async LoadCredentials(companyIntegration, contextUser) {
1285
+ let fromCredential = null;
1286
+ if (companyIntegration.CredentialID) {
1287
+ try {
1288
+ const md = new Metadata();
1289
+ const credential = await md.GetEntityObject('MJ: Credentials', contextUser);
1290
+ const loaded = await credential.Load(companyIntegration.CredentialID);
1291
+ if (loaded && credential.Values)
1292
+ fromCredential = this.ParseCredentialJSON(credential.Values);
1293
+ }
1294
+ catch {
1295
+ // A credential the connection cannot load is a configuration problem, not a crash: the
1296
+ // Configuration fallback below still applies and Authenticate reports what is missing.
1297
+ }
1298
+ }
1299
+ const fromConfig = this.ParseCredentialJSON(companyIntegration.Configuration);
1300
+ return {
1301
+ SiteUrl: fromCredential?.SiteUrl ?? fromConfig?.SiteUrl,
1302
+ ApiKey: fromCredential?.ApiKey ?? fromConfig?.ApiKey,
1303
+ };
1304
+ }
1305
+ /** Extracts the two Elevate credential fields from a credential/Configuration JSON string. */
1306
+ ParseCredentialJSON(json) {
1307
+ const parsed = this.ParseJSONObject(json);
1308
+ if (!parsed)
1309
+ return null;
1310
+ return {
1311
+ SiteUrl: this.FirstString(parsed, ['siteUrl', 'SiteUrl', 'site_url', 'BaseURL', 'baseURL', 'BaseUrl', 'baseUrl']),
1312
+ ApiKey: this.FirstString(parsed, ['apiKey', 'ApiKey', 'api_key', 'APIKey', 'key']),
1313
+ };
1314
+ }
1315
+ // ── Small utilities ───────────────────────────────────────────────────────
1316
+ /** Merges the credential into the request body. The ONLY place the api_key ever touches the wire. */
1317
+ WithCredential(auth, body) {
1318
+ const merged = { api_key: auth.ApiKey };
1319
+ const asObject = this.AsObject(body);
1320
+ if (asObject)
1321
+ Object.assign(merged, asObject);
1322
+ return merged;
1323
+ }
1324
+ /** Removes any occurrence of the credential from a string before it reaches a log or an error. */
1325
+ Redact(auth, text) {
1326
+ if (!auth.ApiKey)
1327
+ return text;
1328
+ return text.split(auth.ApiKey).join('***');
1329
+ }
1330
+ /** The vendor's message from either observed envelope, or undefined when the body carries no error. */
1331
+ VendorMessage(body) {
1332
+ const obj = this.AsObject(body);
1333
+ if (!obj)
1334
+ return undefined;
1335
+ const error = this.AsObject(obj.error);
1336
+ if (error && typeof error.message === 'string')
1337
+ return error.message;
1338
+ if (typeof obj.error === 'string' && obj.error.length > 0)
1339
+ return obj.error;
1340
+ const messages = this.AsObject(obj.error_messages);
1341
+ if (messages) {
1342
+ const parts = Object.entries(messages).map(([k, v]) => `${k}: ${String(v)}`);
1343
+ return parts.length > 0 ? parts.join('; ') : 'error_messages';
1344
+ }
1345
+ return undefined;
1346
+ }
1347
+ /** Pulls the offending column out of the door's own `Field <name> doesn't exist` message. */
1348
+ UnknownFieldFrom(message) {
1349
+ if (!message)
1350
+ return null;
1351
+ const m = /field\s+([A-Za-z0-9_.]+)\s+does\s*n[o']?t\s+exist/i.exec(message);
1352
+ return m ? m[1] : null;
1353
+ }
1354
+ /** Walks a dotted path into a parsed body. Returns undefined at the first missing/non-object segment. */
1355
+ ReadPath(source, path) {
1356
+ let cursor = source;
1357
+ for (const segment of path) {
1358
+ const asObject = this.AsObject(cursor);
1359
+ if (!asObject || !(segment in asObject))
1360
+ return undefined;
1361
+ cursor = asObject[segment];
1362
+ }
1363
+ return cursor;
1364
+ }
1365
+ /** Joins a base URL with an API path exactly as declared — no path is invented or normalised away. */
1366
+ JoinURL(baseURL, apiPath) {
1367
+ const base = baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL;
1368
+ const path = apiPath.startsWith('/') ? apiPath : `/${apiPath}`;
1369
+ return `${base}${path}`;
1370
+ }
1371
+ /** Path-only view of a URL, for messages that must never carry a query string or a credential. */
1372
+ PathOf(url) {
1373
+ try {
1374
+ return new URL(url).pathname;
1375
+ }
1376
+ catch {
1377
+ return url;
1378
+ }
1379
+ }
1380
+ /** Cache key scoping a per-tenant discovery to one connection + object. */
1381
+ CacheKey(companyIntegration, objectName) {
1382
+ return `${companyIntegration.ID}::${objectName}`;
1383
+ }
1384
+ /** Deterministic ordering so discovery output is byte-stable across passes. */
1385
+ SortedNames(names) {
1386
+ return names ? [...names].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) : [];
1387
+ }
1388
+ /** A narrowing cast to a plain object, or null. */
1389
+ AsObject(value) {
1390
+ return value != null && typeof value === 'object' && !Array.isArray(value)
1391
+ ? value
1392
+ : null;
1393
+ }
1394
+ /** First non-empty string value among `keys` on a parsed object. */
1395
+ FirstString(source, keys) {
1396
+ if (!source)
1397
+ return undefined;
1398
+ for (const key of keys) {
1399
+ const v = source[key];
1400
+ if (typeof v === 'string' && v.trim().length > 0)
1401
+ return v.trim();
1402
+ }
1403
+ return undefined;
1404
+ }
1405
+ /** A trimmed string from the connection Configuration JSON. */
1406
+ ConfigString(companyIntegration, keys) {
1407
+ return this.FirstString(this.ParseJSONObject(companyIntegration.Configuration), keys);
1408
+ }
1409
+ /** A finite number from the connection Configuration JSON. */
1410
+ ConfigNumber(companyIntegration, keys) {
1411
+ const cfg = this.ParseJSONObject(companyIntegration.Configuration);
1412
+ if (!cfg)
1413
+ return undefined;
1414
+ for (const key of keys) {
1415
+ const v = cfg[key];
1416
+ const n = typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : NaN;
1417
+ if (Number.isFinite(n))
1418
+ return n;
1419
+ }
1420
+ return undefined;
1421
+ }
1422
+ /** Tolerant JSON-object parse; malformed configuration degrades to "absent" rather than crashing a sync. */
1423
+ ParseJSONObject(json) {
1424
+ if (!json || json.trim().length === 0)
1425
+ return null;
1426
+ try {
1427
+ return this.AsObject(JSON.parse(json));
1428
+ }
1429
+ catch {
1430
+ return null;
1431
+ }
1432
+ }
1433
+ /** `YYYY-MM-DD` for today, in UTC — the granularity the probe partitioned with. */
1434
+ Today() {
1435
+ return new Date().toISOString().slice(0, 10);
1436
+ }
1437
+ /** Normalises a watermark/config value to `YYYY-MM-DD`, or null when it is not a usable date. */
1438
+ ToDayString(value) {
1439
+ if (value == null)
1440
+ return null;
1441
+ const trimmed = String(value).trim();
1442
+ if (trimmed.length === 0)
1443
+ return null;
1444
+ if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed))
1445
+ return trimmed;
1446
+ const parsed = Date.parse(trimmed);
1447
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString().slice(0, 10) : null;
1448
+ }
1449
+ /** `YYYY-MM-DD` + n days, UTC. */
1450
+ AddDays(day, n) {
1451
+ const base = Date.parse(`${day}T00:00:00.000Z`);
1452
+ return new Date(base + n * 86_400_000).toISOString().slice(0, 10);
1453
+ }
1454
+ /** Whole days between two `YYYY-MM-DD` values. */
1455
+ DayDiff(from, to) {
1456
+ return Math.round((Date.parse(`${to}T00:00:00.000Z`) - Date.parse(`${from}T00:00:00.000Z`)) / 86_400_000);
1457
+ }
1458
+ /** The earlier of two `YYYY-MM-DD` values (lexicographic order is chronological for this format). */
1459
+ MinDay(a, b) {
1460
+ return a <= b ? a : b;
1461
+ }
1462
+ /** Sleeps, bounded. Only ever reached on a 429/503 with a honoured `Retry-After`. */
1463
+ async Sleep(ms) {
1464
+ const bounded = Math.max(0, Math.min(MAX_RETRY_AFTER_MS, ms));
1465
+ if (bounded === 0)
1466
+ return;
1467
+ await new Promise(resolve => setTimeout(resolve, bounded));
1468
+ }
1469
+ /** An error message safe to log: never carries credential bytes. */
1470
+ SafeMessage(err) {
1471
+ const raw = err instanceof Error ? err.message : String(err);
1472
+ for (const auth of this.authCache.values()) {
1473
+ if (auth.ApiKey && raw.includes(auth.ApiKey))
1474
+ return this.Redact(auth, raw);
1475
+ }
1476
+ return raw;
1477
+ }
1478
+ /** Emits a warning at most once per connector lifetime, so the log stays honest rather than noisy. */
1479
+ WarnOnce(key, message) {
1480
+ if (this.warnedOnce.has(key))
1481
+ return;
1482
+ this.warnedOnce.add(key);
1483
+ console.warn(message);
1484
+ }
1485
+ };
1486
+ ElevateConnector = __decorate([
1487
+ RegisterClass(BaseIntegrationConnector, 'ElevateConnector')
1488
+ ], ElevateConnector);
1489
+ export { ElevateConnector };
1490
+ //# sourceMappingURL=ElevateConnector.js.map