@memberjunction/connector-eventscribe 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,1758 @@
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 { AsyncLocalStorage } from 'node:async_hooks';
8
+ import { z } from 'zod';
9
+ import { RegisterClass } from '@memberjunction/global';
10
+ import { Metadata } from '@memberjunction/core';
11
+ import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
12
+ import { BaseIntegrationConnector, BaseRESTIntegrationConnector, computeContentHash, serializeKeyValue, } from '@memberjunction/integration-engine';
13
+ import { mergeDeclaredWithSampledFields } from '@memberjunction/connector-schema-merge';
14
+ // ─── Design note — WHAT THIS VENDOR ACTUALLY IS ───────────────────────────────
15
+ //
16
+ // Cadmium/Eventscribe is NOT a resource-oriented REST API. `Configuration.ReadContract` (metadata)
17
+ // states it verbatim: "RPC-over-querystring ... Multiple, sometimes dozens of, unrelated operations
18
+ // share the exact same URL and even the same HTTP verb -- the `Method` query param IS the
19
+ // routing/dispatch mechanism". Three consequences shape every override in this file:
20
+ //
21
+ // 1. THE CREDENTIAL IS A QUERY PARAM, NOT A HEADER. `Configuration.AuthCredentialTransport` =
22
+ // 'query-param', `AuthHeaderPattern` = null. `BuildHeaders` therefore carries NOTHING
23
+ // auth-related; the credential is injected in the request-building path (`SendRequest`).
24
+ // A Bearer/Basic header here would simply never authenticate.
25
+ //
26
+ // 2. THERE IS NO SINGLE BASE URL. `Configuration.BaseURLsByFamily` carries FIVE family tags across
27
+ // THREE hosts, and `BaseURLsByFamilyNote` is explicit that a connector which bakes one base URL —
28
+ // or which collapses 'asset' and 'eventscribe-web' because they happen to resolve to the same
29
+ // host — produces a runtime failure. `GetBaseURL` therefore resolves PER OBJECT from that object's
30
+ // own metadata. The base class's `GetBaseURL(ci, auth)` signature has no object slot, so the
31
+ // per-call object identity rides an AsyncLocalStorage scope (see {@link scope}) rather than a
32
+ // mutable field that two concurrent pushes would race on.
33
+ //
34
+ // 3. THE OBJECT UNIVERSE IS LARGER THAN ITS DOORS. Roughly half the declared objects carry
35
+ // `accessPath.depth >= 1`: they have NO read operation of their own and arrive nested inside a
36
+ // door operation's response under a declared container key. `FetchChanges` walks that access path
37
+ // instead of assuming one flat query per object — assuming flat would pull ZERO rows for each.
38
+ // (The count is deliberately not written down here; it is whatever the metadata declares.)
39
+ //
40
+ // NO CATALOG LIVES IN THIS FILE. Cadmium publishes no describe/list endpoint anywhere in the corpus
41
+ // (`Configuration.DiscoveryIsAuthoritative` = false), so the DECLARED IntegrationObject /
42
+ // IntegrationObjectField rows ARE the catalog. `DiscoverObjects`/`DiscoverFields` are deliberately NOT
43
+ // overridden: the base implementations read those rows back through the engine cache, which is the
44
+ // only correct source. A literal object/field array in this file would be the frozen-catalog defect.
45
+ // ─── Metadata shapes (parsed, never guessed) ──────────────────────────────────
46
+ /** `IntegrationObject.Configuration.accessPath` — how this object's records are actually reached. */
47
+ const ZAccessPath = z
48
+ .object({
49
+ doorOperation: z.string().optional(),
50
+ doorObject: z.string().optional(),
51
+ nestingFieldPath: z.string().optional(),
52
+ depth: z.number().optional(),
53
+ isArray: z.boolean().optional(),
54
+ })
55
+ .passthrough();
56
+ /** `IntegrationObject.Configuration.dispatch` — the RPC routing facts for this object's read door. */
57
+ const ZDispatch = z
58
+ .object({
59
+ mechanism: z.string().optional(),
60
+ methodParamName: z.string().optional(),
61
+ methodValue: z.string().nullable().optional(),
62
+ })
63
+ .passthrough();
64
+ /** `IntegrationObject.Configuration.pagination.envelope` — where the page counters live in the body. */
65
+ const ZPaginationEnvelope = z
66
+ .object({
67
+ totalRecordsKey: z.string().optional(),
68
+ totalPagesKey: z.string().optional(),
69
+ currentPageKey: z.string().optional(),
70
+ container: z.string().optional(),
71
+ })
72
+ .passthrough();
73
+ /** `IntegrationObject.Configuration.pagination` — the PROVEN request param + response envelope. */
74
+ const ZPagination = z
75
+ .object({
76
+ paramName: z.string().optional(),
77
+ type: z.string().optional(),
78
+ pageSize: z.number().optional(),
79
+ envelope: ZPaginationEnvelope.optional(),
80
+ })
81
+ .passthrough();
82
+ /** A rate-limit fact, at either integration or object scope. */
83
+ const ZRateLimit = z
84
+ .object({
85
+ requestsPerWindow: z.number().optional(),
86
+ windowMs: z.number().optional(),
87
+ scope: z.string().optional(),
88
+ })
89
+ .passthrough();
90
+ /** `IntegrationObject.Configuration.writeOperation` / `.deleteOperation`. */
91
+ const ZWriteOperation = z
92
+ .object({
93
+ operationId: z.string().optional(),
94
+ verb: z.string().optional(),
95
+ idParam: z.string().nullable().optional(),
96
+ bodyShape: z.string().nullable().optional(),
97
+ requestShape: z.string().optional(),
98
+ createIDLocation: z.string().nullable().optional(),
99
+ })
100
+ .passthrough();
101
+ /**
102
+ * `IntegrationObject.Configuration.watermark` — the vendor's OWN server-side time-window filter for
103
+ * one object's read door, when it documents one. Absent ⇒ the object has no incremental mechanism and
104
+ * a full pull is the only honest read (this vendor's `watermarkProvenNegative` says so per object).
105
+ */
106
+ const ZWatermark = z
107
+ .object({
108
+ field: z.string().optional(),
109
+ startParam: z.string().optional(),
110
+ endParam: z.string().optional(),
111
+ valueFormat: z.string().optional(),
112
+ urlEncodeRequired: z.boolean().optional(),
113
+ })
114
+ .passthrough();
115
+ /** `IntegrationObject.Configuration.outOfScope` — why an emitted object ships Disabled. */
116
+ const ZOutOfScope = z
117
+ .object({
118
+ family: z.string().optional(),
119
+ emittedButDisabled: z.boolean().optional(),
120
+ credentialModel: z.string().optional(),
121
+ })
122
+ .passthrough();
123
+ const ZObjectConfig = z
124
+ .object({
125
+ family: z.string().optional(),
126
+ absoluteEndpoint: z.string().optional(),
127
+ baseUrl: z.string().optional(),
128
+ dispatch: ZDispatch.optional(),
129
+ accessPath: ZAccessPath.optional(),
130
+ pagination: ZPagination.optional(),
131
+ rateLimit: ZRateLimit.optional(),
132
+ nestedContainerKey: z.string().optional(),
133
+ parentObjectName: z.string().optional(),
134
+ parentObjectIDFieldName: z.string().optional(),
135
+ requiresRecordKeyToRead: z.string().optional(),
136
+ responseFormat: z.string().optional(),
137
+ watermark: ZWatermark.optional(),
138
+ outOfScope: ZOutOfScope.optional(),
139
+ writeOperation: ZWriteOperation.optional(),
140
+ deleteOperation: ZWriteOperation.optional(),
141
+ })
142
+ .passthrough();
143
+ const ZFamilyBaseURL = z.object({ family: z.string(), baseUrl: z.string() }).passthrough();
144
+ const ZRateLimitOverride = z
145
+ .object({
146
+ methods: z.array(z.string()).optional(),
147
+ requestsPerWindow: z.number().optional(),
148
+ windowMs: z.number().optional(),
149
+ })
150
+ .passthrough();
151
+ const ZIntegrationConfig = z
152
+ .object({
153
+ AuthCredentialParamName: z.string().optional(),
154
+ AuthMultiTenantParam: z
155
+ .object({ name: z.string().optional(), required: z.boolean().optional() })
156
+ .passthrough()
157
+ .optional(),
158
+ ReadContract: z.object({ methodParamName: z.string().optional() }).passthrough().optional(),
159
+ BaseURLsByFamily: z.array(ZFamilyBaseURL).optional(),
160
+ /**
161
+ * Single-origin OVERRIDE. When set, EVERY object resolves to this origin regardless of
162
+ * family — the deliberate escape hatch for pointing the whole connector at one host that
163
+ * is standing in for all five Cadmium hosts: a mock server, a sandbox, or a corporate
164
+ * proxy. Unset in production, where {@link BaseURLsByFamily} is the real resolver.
165
+ *
166
+ * This is NOT the "baked host" the family map exists to prevent: it is explicit
167
+ * configuration supplied per connection, never a constant in this file.
168
+ */
169
+ BaseURL: z.string().optional(),
170
+ RateLimits: z
171
+ .object({ standard: ZRateLimit.optional(), overrides: z.array(ZRateLimitOverride).optional() })
172
+ .passthrough()
173
+ .optional(),
174
+ BatchSemantics: z.record(z.unknown()).optional(),
175
+ })
176
+ .passthrough();
177
+ /** Typed transport failure carrying the vendor's own message and the classified verdict. */
178
+ export class EventscribeAPIError extends Error {
179
+ constructor(message, Status, Headers, Classification, VendorMessage) {
180
+ super(message);
181
+ this.Status = Status;
182
+ this.Headers = Headers;
183
+ this.Classification = Classification;
184
+ this.VendorMessage = VendorMessage;
185
+ this.name = 'EventscribeAPIError';
186
+ }
187
+ }
188
+ let EventscribeConnector = class EventscribeConnector extends BaseRESTIntegrationConnector {
189
+ constructor() {
190
+ super(...arguments);
191
+ /**
192
+ * The object + verb the CURRENT async call chain is serving. The base class's `GetBaseURL`,
193
+ * `ExtractPaginationInfo`, `BuildOperationBody` and `ExtractIDFromResponse` hooks are called
194
+ * without an object argument, but every one of them is per-object on this vendor (five families,
195
+ * three hosts, per-object pagination envelope, per-object array-body write convention). An
196
+ * AsyncLocalStorage scope carries the identity correctly even when the engine pushes several
197
+ * objects concurrently — a mutable `this.currentObject` field would silently cross the wires.
198
+ */
199
+ this.scope = new AsyncLocalStorage();
200
+ /** Resolved auth per CompanyIntegration.ID. The APIKey is static; there is nothing to refresh. */
201
+ this.authCache = new Map();
202
+ /** Earliest permitted send time per `host|MethodValue`, so the vendor's documented spacing is honoured. */
203
+ this.nextAllowedAt = new Map();
204
+ /** Warnings already emitted, so a long sync logs honestly rather than noisily. */
205
+ this.warnedOnce = new Set();
206
+ }
207
+ // ── Identity (T1 three-way invariant) ─────────────────────────────────────
208
+ /** Verbatim `MJ: Integrations.Name`. Load-bearing: T1 compares this === the metadata Name. */
209
+ get IntegrationName() {
210
+ return 'eventscribe';
211
+ }
212
+ // ── Capability getters (kept in lockstep with the per-operation IO columns) ──
213
+ /**
214
+ * TRUE. `Configuration.WriteCapability` documents `addUpdateAccount` (eventscribe-web),
215
+ * `addUpdateExhibitor` / `addUpdateBooth` / `addUpdateExhibitorStaff` (expo-harvester) and
216
+ * `addUpdatePresenter` / `addUpdatePresentation` (education-harvester); those objects carry
217
+ * populated `CreateAPIPath` + `CreateMethod` columns and ride the base class's generic create.
218
+ */
219
+ get SupportsCreate() { return true; }
220
+ /** TRUE for the same `addUpdate*` upsert operations — they are create-OR-update in one call. */
221
+ get SupportsUpdate() { return true; }
222
+ /**
223
+ * TRUE, but narrowly: only Account (`cancelAccount` / `deleteAccount`) and Presentation
224
+ * (`deletePresentation`) declare a delete operation. `Configuration.WriteCapability` records
225
+ * expo-harvester's `unassignBooth` as explicitly NOT a delete, and abstract-scorecard as 100%
226
+ * read-only, so those objects leave `DeleteAPIPath` null and the generic delete refuses them.
227
+ */
228
+ get SupportsDelete() { return true; }
229
+ /**
230
+ * FALSE, permanently. `Configuration.DiscoveryIsAuthoritativeReason` (metadata): Cadmium documents
231
+ * NO list/describe/schema endpoint anywhere in the corpus, so "absence from a sample response
232
+ * proves nothing about what the vendor's schema actually supports". A thin runtime result must
233
+ * never deactivate a persisted object or field — that would be tenant-visible data loss.
234
+ */
235
+ get DiscoveryIsAuthoritative() { return false; }
236
+ // ── Sync-efficiency hooks (§7/§10) — each backed by a metadata fact ───────
237
+ /**
238
+ * Read STRAIGHT off `Configuration.RateLimits.standard` ("1 request per 1000 ms, most methods,
239
+ * vendor-wide"). Returns null when the integration row carries no rate-limit facts — the engine
240
+ * then paces itself rather than obeying a number this class invented. The per-METHOD overrides
241
+ * (the two vendor-documented heavy methods at 1/60s) cannot be expressed in this connector-wide
242
+ * policy, so they are enforced per request in {@link PaceRequest}.
243
+ */
244
+ get RateLimitPolicy() {
245
+ const standard = this.IntegrationConfig()?.RateLimits?.standard;
246
+ const perWindow = standard?.requestsPerWindow;
247
+ const windowMs = standard?.windowMs;
248
+ if (!Number.isFinite(perWindow) || !Number.isFinite(windowMs) || windowMs <= 0)
249
+ return null;
250
+ const tokensPerSec = (perWindow * 1000) / windowMs;
251
+ if (!Number.isFinite(tokensPerSec) || tokensPerSec <= 0)
252
+ return null;
253
+ return {
254
+ TokensPerSec: tokensPerSec,
255
+ Burst: Math.max(1, Math.floor(perWindow)),
256
+ ThrottleBackoffFactor: 0.5,
257
+ SuccessRampPerCall: tokensPerSec / 10,
258
+ MinTokensPerSec: tokensPerSec / 20,
259
+ };
260
+ }
261
+ /**
262
+ * One in flight when the vendor's documented standard allowance is one request per window —
263
+ * derived from the same `Configuration.RateLimits.standard` fact, not asserted here. Null when the
264
+ * metadata carries no allowance, so the engine keeps its own default.
265
+ */
266
+ get MaxConcurrencyHint() {
267
+ const perWindow = this.IntegrationConfig()?.RateLimits?.standard?.requestsPerWindow;
268
+ return Number.isFinite(perWindow) && perWindow >= 1 ? Math.floor(perWindow) : null;
269
+ }
270
+ /**
271
+ * Strictly whatever the object's own `StableOrderingKey` column declares. Never synthesised: an
272
+ * invented resume cursor on a source with no server-side ordering guarantee silently skips rows.
273
+ */
274
+ StableOrderingKey(objectName) {
275
+ const integrationID = this.tryGetIntegrationID();
276
+ if (!integrationID)
277
+ return null;
278
+ try {
279
+ return this.GetCachedObject(integrationID, objectName).StableOrderingKey ?? null;
280
+ }
281
+ catch {
282
+ return null;
283
+ }
284
+ }
285
+ /**
286
+ * TRUE. The `addUpdate*` operations are REAL batch endpoints: `Configuration.BatchSemantics`
287
+ * documents "JSON array in the raw POST body (single-object writes still require wrapping in a
288
+ * one-element array)" with per-record, NON-ATOMIC processing. See {@link BatchCreateRecords}.
289
+ */
290
+ get SupportsBatchWrite() { return true; }
291
+ // ── Discovery ─────────────────────────────────────────────────────────────
292
+ //
293
+ // STATIC-CATALOG: Cadmium documents NO list/describe/schema/introspection endpoint anywhere in the
294
+ // corpus (`Configuration.DiscoveryIsAuthoritativeReason`, 11 PDFs + 1 XLSX, zero OpenAPI/Swagger/
295
+ // Postman/GraphQL/SDK artifacts) and publishes no credential-free schema-of-record, so there is no
296
+ // runtime enumeration to call and no public schema to parse — the object/field universe is knowable
297
+ // only from the Declared metadata rows.
298
+ //
299
+ // `DiscoverObjects` and `DiscoverFields` are therefore DELIBERATELY NOT overridden: the Declared
300
+ // IntegrationObject / IntegrationObjectField rows ARE the schema of record, and the base
301
+ // implementations read exactly those rows back through `IntegrationEngineBase` — CREDENTIAL-FREE,
302
+ // which is what keeps the runtime structure self-check green without a token. Writing the object
303
+ // list into this file — even as a `.map()` over a local array — would freeze the catalog AND make
304
+ // the next build read its own output back as a source. A live credential is purely ADDITIVE here:
305
+ // it only adds tenant-specific columns, via the sample-union in `IntrospectSchema` below.
306
+ /**
307
+ * Declared ∪ live-sampled, so a tenant's own columns reach the schema builder. Cadmium's field
308
+ * sets are demonstrably per-tenant (`AccountCustomField1..10`, `AuthorCustomFieldN`,
309
+ * `SubmitterCustomFieldN`, `ReviewAnswerN` are all templated in the vendor docs), and with no
310
+ * describe endpoint the ONLY way to learn which of them a given event actually populates is to
311
+ * read real records. The union is delegated to the shared never-shrink helper; this connector
312
+ * supplies no merge logic of its own, and a sampling failure leaves the DECLARED floor intact.
313
+ */
314
+ async IntrospectSchema(companyIntegration, contextUser) {
315
+ const info = await super.IntrospectSchema(companyIntegration, contextUser);
316
+ await Promise.all(info.Objects.map(async (obj) => {
317
+ try {
318
+ const sampled = await this.DiscoverFieldsViaFetch(companyIntegration, obj.ExternalName, contextUser);
319
+ obj.Fields = mergeDeclaredWithSampledFields(obj.Fields, sampled);
320
+ }
321
+ catch (err) {
322
+ this.WarnOnce(`introspect:${obj.ExternalName}`, `[eventscribe] Live field sampling for "${obj.ExternalName}" failed (${this.SafeMessage(err)}); ` +
323
+ 'the DECLARED field floor still stands. Nothing was removed — Cadmium publishes no describe ' +
324
+ 'endpoint, so absence proves nothing.');
325
+ }
326
+ }));
327
+ return info;
328
+ }
329
+ // ── Connection test ───────────────────────────────────────────────────────
330
+ /**
331
+ * Runs the cheapest real read this connection can make: the first ACTIVE, directly-queryable
332
+ * object's own door. Objects whose only door needs a caller-supplied record key
333
+ * (`Configuration.requiresRecordKeyToRead`) are skipped — calling them unkeyed proves nothing.
334
+ * The message never carries credential bytes.
335
+ */
336
+ async TestConnection(companyIntegration, contextUser) {
337
+ try {
338
+ const objects = this.getCachedObjects(companyIntegration.IntegrationID);
339
+ if (objects.length === 0) {
340
+ return {
341
+ Success: false,
342
+ Message: '[eventscribe] No ACTIVE IntegrationObjects are seeded for this integration, so there is ' +
343
+ 'no door to probe. Push metadata/integrations/eventscribe before testing the connection.',
344
+ };
345
+ }
346
+ const probe = objects.find((o) => {
347
+ const cfg = this.ObjectConfig(o);
348
+ return this.DepthOf(cfg) === 0 && cfg?.requiresRecordKeyToRead == null && this.DoorOperationFor(o, cfg) != null;
349
+ });
350
+ if (!probe) {
351
+ return {
352
+ Success: false,
353
+ Message: '[eventscribe] Every ACTIVE object either has no read door or declares ' +
354
+ 'requiresRecordKeyToRead, so no credential-only probe exists. Enable an enumerable object ' +
355
+ '(for example one of the abstract-scorecard get* doors) before testing.',
356
+ };
357
+ }
358
+ const auth = await this.Authenticate(companyIntegration, contextUser);
359
+ const url = this.DoorURL(companyIntegration, auth, probe, this.DoorOperationFor(probe, this.ObjectConfig(probe)));
360
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', this.BuildHeaders(auth));
361
+ if (response.Status >= 200 && response.Status < 300) {
362
+ return {
363
+ Success: true,
364
+ Message: `[eventscribe] Reachable: door "${probe.Name}" answered HTTP ${response.Status} for this API key.`,
365
+ };
366
+ }
367
+ return {
368
+ Success: false,
369
+ Message: `[eventscribe] Door "${probe.Name}" answered HTTP ${response.Status}` +
370
+ `${this.VendorMessage(response.Body) ? `: ${this.VendorMessage(response.Body)}` : ''}. ` +
371
+ 'Check the API key and, for a multi-event key, the event id (eID) on this connection.',
372
+ };
373
+ }
374
+ catch (err) {
375
+ return { Success: false, Message: `[eventscribe] Connection test failed: ${this.SafeMessage(err)}` };
376
+ }
377
+ }
378
+ // ── The READ path ─────────────────────────────────────────────────────────
379
+ /**
380
+ * OVERRIDDEN for two evidenced reasons, and it delegates back to the base for everything else.
381
+ *
382
+ * (a) MULTI-HOST. The object's identity has to be in scope before `GetBaseURL` runs, because the
383
+ * base's signature has no object slot and this vendor has three hosts.
384
+ * (b) NESTED ACCESS PATHS. A large minority of the declared objects carry `accessPath.depth >= 1`
385
+ * with the note "This object has no read operation of its own; its records arrive nested
386
+ * inside the door operation's response under the '<key>' key." A flat per-object query
387
+ * returns ZERO rows for every one of them, so those walk the declared path instead.
388
+ *
389
+ * A depth-0 object goes straight back to `super.FetchChanges` — the base's pagination loop,
390
+ * batch limiting and record assembly are used AS IS. Two metadata-declared refusals run FIRST
391
+ * ({@link WireFormatGate}, {@link RecordKeyGate}) so an object this connector cannot honestly read
392
+ * reports a structured warning instead of a silent, green, zero-row batch.
393
+ */
394
+ async FetchChanges(ctx) {
395
+ const companyIntegration = ctx.CompanyIntegration;
396
+ const obj = this.GetCachedObject(companyIntegration.IntegrationID, ctx.ObjectName);
397
+ const cfg = this.ObjectConfig(obj);
398
+ const wireGate = this.WireFormatGate(obj, cfg);
399
+ if (wireGate)
400
+ return { Records: [], HasMore: false, Warnings: [wireGate] };
401
+ const gate = this.RecordKeyGate(companyIntegration, obj, cfg);
402
+ if (gate)
403
+ return { Records: [], HasMore: false, Warnings: [gate] };
404
+ const callScope = {
405
+ IntegrationID: companyIntegration.IntegrationID,
406
+ ObjectName: ctx.ObjectName,
407
+ Verb: 'read',
408
+ WindowParams: this.WindowParamsFor(obj, cfg, ctx),
409
+ };
410
+ const batch = this.DepthOf(cfg) === 0
411
+ ? await this.scope.run(callScope, () => super.FetchChanges(ctx))
412
+ : await this.scope.run(callScope, () => this.FetchNestedViaDoor(ctx, obj, cfg));
413
+ // Reached ONLY on a batch that completed without throwing — a mid-iteration failure propagates
414
+ // out of the awaits above, so the watermark is never advanced over a partial read.
415
+ return this.WithMaxSeenWatermark(batch, obj, ctx);
416
+ }
417
+ /**
418
+ * Refuses an object whose metadata declares a wire format this connector does not parse.
419
+ * `Configuration.responseFormat` is a per-object FACT: the five in-scope families are all `json`,
420
+ * while the EdgeReg family ships `xml` (and its own `responseFormatNote`: "The connector must parse
421
+ * XML for this object") together with a DIFFERENT credential model. Those objects are seeded
422
+ * `Status = 'Disabled'` so they normally never reach a sync at all; this gate is what happens if an
423
+ * operator activates one anyway. Without it the XML body fails to parse as JSON, `NormalizeResponse`
424
+ * yields `[]`, and the run reports zero rows and GREEN — the silent-empty this framework exists to
425
+ * prevent. The refusal names the declared format and the credential model so the fix is obvious.
426
+ */
427
+ WireFormatGate(obj, cfg) {
428
+ const declared = cfg?.responseFormat;
429
+ if (!declared || declared.trim().toLowerCase() === 'json')
430
+ return null;
431
+ const credentialModel = cfg?.outOfScope?.credentialModel;
432
+ return {
433
+ Code: 'UNSUPPORTED_WIRE_FORMAT',
434
+ Message: `"${obj.Name}" declares Configuration.responseFormat = "${declared}", which this connector does ` +
435
+ 'not parse — it speaks the JSON families only. Refusing to fire the request rather than return ' +
436
+ 'an empty batch that would read as "this event has no records". ' +
437
+ (credentialModel
438
+ ? `This object also declares a different credential model (${credentialModel}), so the ` +
439
+ 'connection\'s Eventscribe APIKey would not authenticate it either. '
440
+ : '') +
441
+ 'Leave the object Disabled until a build adds the parser and the credential.',
442
+ Data: {
443
+ object: obj.Name,
444
+ responseFormat: declared,
445
+ family: cfg?.family ?? null,
446
+ credentialModel: credentialModel ?? null,
447
+ },
448
+ };
449
+ }
450
+ /**
451
+ * The DECLARED incremental window for one read, or undefined for a full pull. Everything is
452
+ * metadata: the parameter name comes from the object's own `Configuration.watermark.startParam`,
453
+ * and the VALUE is the watermark the engine handed back — which this connector originally took
454
+ * from the record's OWN `IncrementalWatermarkField` (see {@link WithMaxSeenWatermark}), so it is
455
+ * already in the vendor's own serialization and no format is invented on the wire.
456
+ *
457
+ * `endParam` is deliberately NOT sent even where declared: an upper bound would silently drop any
458
+ * record the vendor writes between the request being built and being served. No `startParam` in
459
+ * metadata ⇒ no window — this vendor's per-object `watermarkProvenNegative` records that the five
460
+ * in-scope families document no server-side modified-since filter at all, and an invented one
461
+ * either returns nothing or is ignored.
462
+ */
463
+ WindowParamsFor(obj, cfg, ctx) {
464
+ if (!obj.SupportsIncrementalSync)
465
+ return undefined;
466
+ const startParam = cfg?.watermark?.startParam;
467
+ if (!startParam)
468
+ return undefined;
469
+ const since = ctx.WatermarkValue;
470
+ if (since == null || String(since).trim().length === 0)
471
+ return undefined;
472
+ return { [startParam]: String(since).trim() };
473
+ }
474
+ /**
475
+ * Advances the watermark to the MAX value SEEN in this batch, and only for an object whose metadata
476
+ * declares one (`SupportsIncrementalSync` + `IncrementalWatermarkField`). Never advances past a
477
+ * value already recorded, and never invents a watermark for a full-pull object — the five in-scope
478
+ * families are all `FullPullHashDiff`, where the engine's content-hash idempotency does the work.
479
+ */
480
+ WithMaxSeenWatermark(batch, obj, ctx) {
481
+ const field = obj.SupportsIncrementalSync ? obj.IncrementalWatermarkField : null;
482
+ if (!field)
483
+ return batch;
484
+ let max = null;
485
+ for (const record of batch.Records) {
486
+ const raw = record.Fields[field];
487
+ if (raw == null)
488
+ continue;
489
+ const value = String(raw).trim();
490
+ if (value.length === 0)
491
+ continue;
492
+ if (max == null || this.CompareWatermark(value, max) > 0)
493
+ max = value;
494
+ }
495
+ if (max == null)
496
+ return batch;
497
+ if (ctx.WatermarkValue != null && this.CompareWatermark(max, ctx.WatermarkValue) <= 0)
498
+ return batch;
499
+ return { ...batch, NewWatermarkValue: max };
500
+ }
501
+ /** Chronological when BOTH values parse as dates, lexicographic otherwise. Never coerces one side. */
502
+ CompareWatermark(a, b) {
503
+ const ta = Date.parse(a);
504
+ const tb = Date.parse(b);
505
+ if (Number.isFinite(ta) && Number.isFinite(tb))
506
+ return ta === tb ? 0 : (ta < tb ? -1 : 1);
507
+ return a === b ? 0 : (a < b ? -1 : 1);
508
+ }
509
+ /**
510
+ * Walks a declared nesting path: fire the DOOR operation, then descend into the declared container
511
+ * key on each door record and emit the leaf rows. Everything that varies — the door's Method value,
512
+ * the container key, the parent key field to tag the leaf with — comes from the object's own
513
+ * `Configuration.accessPath` / `nestedContainerKey` / `parentObjectIDFieldName`; nothing is guessed.
514
+ *
515
+ * Returns ONE batch with `HasMore: false`. Every depth>=1 object declares
516
+ * `SupportsPagination = false`, so the door is a single unpaged call: splitting the leaves across
517
+ * batches would force a full re-read of the door per batch for no benefit.
518
+ */
519
+ async FetchNestedViaDoor(ctx, obj, cfg) {
520
+ const companyIntegration = ctx.CompanyIntegration;
521
+ const doorOperation = this.DoorOperationFor(obj, cfg);
522
+ if (!doorOperation) {
523
+ throw new Error(`[eventscribe] "${obj.Name}" is a nested object (accessPath.depth ` +
524
+ `${this.DepthOf(cfg)}) but declares no accessPath.doorOperation, dispatch.methodValue or ` +
525
+ 'DefaultQueryParams Method. There is no operation to call — refusing to invent one.');
526
+ }
527
+ const containerKey = this.NestedContainerKeyFor(cfg);
528
+ if (!containerKey) {
529
+ throw new Error(`[eventscribe] "${obj.Name}" is nested under door "${doorOperation}" but declares no ` +
530
+ 'Configuration.nestedContainerKey and no accessPath.nestingFieldPath to derive it from. ' +
531
+ 'Refusing to guess which response key carries its records.');
532
+ }
533
+ const parentIDField = cfg?.parentObjectIDFieldName ?? null;
534
+ const doorObjectName = cfg?.accessPath?.doorObject ?? cfg?.parentObjectName ?? null;
535
+ const doorObject = doorObjectName
536
+ ? this.TryGetCachedObject(companyIntegration.IntegrationID, doorObjectName)
537
+ : null;
538
+ const auth = await this.Authenticate(companyIntegration, ctx.ContextUser);
539
+ const url = this.DoorURL(companyIntegration, auth, obj, doorOperation);
540
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', this.BuildHeaders(auth));
541
+ if (response.Status < 200 || response.Status >= 300) {
542
+ throw this.ErrorFor(response, url);
543
+ }
544
+ const doorRows = this.NormalizeResponse(response.Body, obj.ResponseDataKey ?? doorObject?.ResponseDataKey ?? null);
545
+ const fields = this.GetCachedFields(obj.ID);
546
+ const pkNames = this.PrimaryKeyNames(fields);
547
+ const records = [];
548
+ for (const doorRow of doorRows) {
549
+ const container = doorRow[containerKey];
550
+ if (container == null)
551
+ continue;
552
+ const items = Array.isArray(container) ? container : [container];
553
+ const parentID = parentIDField != null ? doorRow[parentIDField] : undefined;
554
+ for (const item of items) {
555
+ const leaf = this.AsObject(item);
556
+ if (!leaf)
557
+ continue;
558
+ // FULL-RECORD PASS-THROUGH: the complete nested row, plus the parent key the vendor's
559
+ // nested payload omits. A value the source itself supplied is never overwritten.
560
+ const raw = { ...leaf };
561
+ if (parentIDField && parentID != null && !(parentIDField in raw))
562
+ raw[parentIDField] = parentID;
563
+ records.push(this.ToEventscribeRecord(this.applyTransformPreservingKeys(raw, obj, fields), ctx.ObjectName, pkNames, obj));
564
+ }
565
+ }
566
+ const warnings = [];
567
+ if (records.length === 0) {
568
+ warnings.push({
569
+ Code: 'EMPTY_NESTED_CONTAINER',
570
+ Message: `"${obj.Name}": door "${doorOperation}" returned ${doorRows.length} record(s) but none carried a ` +
571
+ `"${containerKey}" container, so no nested rows were emitted. Either this event has none, or the ` +
572
+ 'declared container key no longer matches the vendor payload.',
573
+ Data: { object: obj.Name, door: doorOperation, containerKey, doorRows: doorRows.length },
574
+ });
575
+ }
576
+ return { Records: records, HasMore: false, Warnings: warnings.length > 0 ? warnings : undefined };
577
+ }
578
+ /**
579
+ * The honest refusal for an object whose ONLY read door needs a caller-supplied record key.
580
+ * `Configuration.requiresRecordKeyToRead` states it verbatim: "A full sync cannot enumerate this
581
+ * object unaided". The vendor documents no parameter name for that key on the read side, so this
582
+ * returns an empty batch with a loud, structured warning rather than firing a call that is
583
+ * guaranteed to fail — or, worse, inventing a query parameter.
584
+ */
585
+ RecordKeyGate(companyIntegration, obj, cfg) {
586
+ const own = cfg?.requiresRecordKeyToRead;
587
+ if (own) {
588
+ return {
589
+ Code: 'REQUIRES_RECORD_KEY',
590
+ Message: `"${obj.Name}": ${own}`,
591
+ Data: { object: obj.Name, door: this.DoorOperationFor(obj, cfg) },
592
+ };
593
+ }
594
+ if (this.DepthOf(cfg) === 0)
595
+ return null;
596
+ const doorObjectName = cfg?.accessPath?.doorObject ?? cfg?.parentObjectName;
597
+ if (!doorObjectName)
598
+ return null;
599
+ const doorObject = this.TryGetCachedObject(companyIntegration.IntegrationID, doorObjectName);
600
+ const doorGate = doorObject ? this.ObjectConfig(doorObject)?.requiresRecordKeyToRead : undefined;
601
+ if (!doorGate)
602
+ return null;
603
+ return {
604
+ Code: 'REQUIRES_RECORD_KEY',
605
+ Message: `"${obj.Name}" is nested under door object "${doorObjectName}", whose only read operation needs a ` +
606
+ `caller-supplied record key: ${doorGate}`,
607
+ Data: { object: obj.Name, doorObject: doorObjectName },
608
+ };
609
+ }
610
+ // ── REST transport primitives ─────────────────────────────────────────────
611
+ /**
612
+ * Resolves the per-connection credential. Cadmium's APIKey is a single static per-client string
613
+ * with no authorize/token endpoint, no scopes and no documented expiry, so there is nothing to
614
+ * refresh and the resolved context is cached per CompanyIntegration. `eID` is the per-CONNECTION
615
+ * (TENANT) event scope — `Configuration.AuthMultiTenantParam` is explicit that it is "never baked
616
+ * into connector code" — and is optional, used only when the key is provisioned for multi-event
617
+ * access. The credential is read through the standard `MJ: Credentials` record when the connection
618
+ * carries one, with the connection's own `Configuration` JSON as the fallback. No inline crypto.
619
+ */
620
+ async Authenticate(companyIntegration, contextUser) {
621
+ const cached = this.authCache.get(companyIntegration.ID);
622
+ if (cached)
623
+ return cached;
624
+ const creds = await this.LoadCredentials(companyIntegration, contextUser);
625
+ const apiKey = (creds.APIKey ?? '').trim();
626
+ if (!apiKey) {
627
+ throw new Error('[eventscribe] No API key configured. Cadmium carries its credential as a QUERY PARAMETER on ' +
628
+ 'every request (Configuration.AuthCredentialTransport = "query-param"), so no call can be made ' +
629
+ 'without it. Supply "APIKey" on the connection credential or its Configuration JSON.');
630
+ }
631
+ const eventID = (creds.EventID ?? '').trim();
632
+ const ctx = {
633
+ APIKey: apiKey,
634
+ IntegrationID: companyIntegration.IntegrationID,
635
+ };
636
+ if (eventID.length > 0)
637
+ ctx.EventID = eventID;
638
+ this.authCache.set(companyIntegration.ID, ctx);
639
+ return ctx;
640
+ }
641
+ /**
642
+ * Transport headers ONLY — deliberately NOTHING auth-related. `Configuration.AuthHeaderPattern` is
643
+ * null and `AuthCredentialParamLocation` is 'query': the API key travels in the query string, and a
644
+ * Bearer/Basic header on this vendor is simply wrong. The credential is injected in
645
+ * {@link SendRequest}, which is the one place it ever touches the wire.
646
+ */
647
+ BuildHeaders(_auth) {
648
+ return { 'Accept': 'application/json', 'Content-Type': 'application/json' };
649
+ }
650
+ /**
651
+ * The wire choke point used by every read and every single-record write. On top of
652
+ * {@link SendRequest} (credential injection, vendor pacing, the documented 404+`[]` empty case) it
653
+ * adds ONE rule: a 2xx response whose body carries the vendor's `{"error": ...}` envelope is a
654
+ * FAILURE, not an empty read. `Configuration.ErrorContract` documents that envelope as applying
655
+ * "across API methods"; a body-blind success check would sync zero rows and report green.
656
+ *
657
+ * On a READ it adds the SAME status gate the connector's other two read call sites already apply
658
+ * ({@link FetchNestedViaDoor}, {@link GetRecord}): a non-2xx never reaches record assembly, and it
659
+ * surfaces as the connector's own classified {@link EventscribeAPIError} rather than an unclassified
660
+ * failure — a 5xx must reach the engine as `Retryable: true`, a 401/403 as a non-retryable
661
+ * configuration error. The flat/paginated read the base class drives was the one transport path
662
+ * with no such gate, so its errors carried no `Code`/`Severity`/`Retryable` verdict at all.
663
+ *
664
+ * OUTSIDE a read, NON-2xx responses are returned, not thrown, so the base class's generic CRUD can
665
+ * build a proper `CRUDResult`, `GetRecord` keeps its documented 404 ⇒ null, and `TestConnection`
666
+ * can report the status it observed. The batch path calls {@link SendRequest} directly because for
667
+ * `addUpdateAccount` an HTTP 400 can accompany partially-succeeded records and must be INSPECTED
668
+ * rather than treated as total failure.
669
+ */
670
+ async MakeHTTPRequest(auth, url, method, headers, body) {
671
+ const response = await this.SendRequest(auth, url, method, headers, body);
672
+ if (response.Status >= 200 && response.Status < 300 && this.VendorMessage(response.Body) != null) {
673
+ throw this.ErrorFor(response, url);
674
+ }
675
+ // The read gate. Scoped to the read verb via the SAME per-call scope every other per-object
676
+ // decision in this class rides, because the base class's pagination loop is private and cannot
677
+ // be overridden — this is the only seam a flat read passes through. The documented 404+`[]`
678
+ // empty case is already normalised to a 200 in {@link SendRequest}, so it stays a success.
679
+ if (this.scope.getStore()?.Verb === 'read' && (response.Status < 200 || response.Status >= 300)) {
680
+ throw this.ErrorFor(response, url);
681
+ }
682
+ return response;
683
+ }
684
+ /**
685
+ * Credential injection + vendor pacing + the documented empty-result special case. Returns the
686
+ * response verbatim for every status; classification is the caller's decision.
687
+ *
688
+ * `Configuration.ErrorContract.specialCases` is explicit for the asset family: HTTP 404 with a body
689
+ * of `[]` means "no presentations or posters were found" — an EMPTY RESULT, never a connector
690
+ * failure. It is normalised to a 200 here so both the base read path (which validates on status)
691
+ * and this connector's own paths see an empty success.
692
+ */
693
+ async SendRequest(auth, url, method, headers, body) {
694
+ const ctx = auth;
695
+ const requestURL = this.WithCredentialParams(ctx, this.WithWindowParams(url));
696
+ await this.PaceRequest(requestURL);
697
+ const response = await this.rawRequest(requestURL, method, headers, body);
698
+ if (response.Status === 404 && Array.isArray(response.Body) && response.Body.length === 0) {
699
+ return { Status: 200, Body: [], Headers: response.Headers };
700
+ }
701
+ return response;
702
+ }
703
+ /** Raw transport. Isolated so tests can substitute it without touching any connector behaviour. */
704
+ async rawRequest(url, method, headers, body) {
705
+ const response = await fetch(url, {
706
+ method,
707
+ headers,
708
+ body: body !== undefined ? JSON.stringify(body) : undefined,
709
+ redirect: 'manual',
710
+ });
711
+ const respHeaders = {};
712
+ response.headers.forEach((v, k) => { respHeaders[k.toLowerCase()] = v; });
713
+ const text = await response.text();
714
+ let parsed = null;
715
+ if (text.length > 0) {
716
+ try {
717
+ parsed = JSON.parse(text);
718
+ }
719
+ catch {
720
+ parsed = text;
721
+ }
722
+ }
723
+ return { Status: response.status, Body: parsed, Headers: respHeaders };
724
+ }
725
+ /**
726
+ * Strips the vendor envelope. A BARE JSON ARRAY is the common shape across this vendor (Asset,
727
+ * Expo, Education Harvester); `ResponseDataKey` is applied ONLY where the object's metadata
728
+ * declares one (the abstract-scorecard family's `{ metadata: {...}, results: [...] }`). A single
729
+ * record object is a one-element result — several doors (`getAccount`, `getSingle*`) answer with
730
+ * one object, not an array — but an error envelope is never mistaken for a record.
731
+ */
732
+ NormalizeResponse(rawBody, responseDataKey) {
733
+ const target = responseDataKey ? this.ReadPath(rawBody, responseDataKey.split('.')) : rawBody;
734
+ if (Array.isArray(target)) {
735
+ return target.filter((r) => this.AsObject(r) != null);
736
+ }
737
+ const single = this.AsObject(target);
738
+ if (!single)
739
+ return [];
740
+ if (this.VendorMessage(single) != null)
741
+ return [];
742
+ return [single];
743
+ }
744
+ /**
745
+ * PageNumber only, and only from the object's OWN declared envelope
746
+ * (`Configuration.pagination.envelope` = `{ container: 'metadata', totalRecordsKey: 'totalRecords',
747
+ * totalPagesKey: 'pages', currentPageKey: 'page' }`). An object whose metadata declares
748
+ * `SupportsPagination = false` never reaches here — the base short-circuits it — and when the
749
+ * envelope is not declared this returns `HasMore: false` rather than inventing a counter name.
750
+ * Inventing one either truncates the sync or loops it forever.
751
+ */
752
+ ExtractPaginationInfo(rawBody, paginationType, currentPage, _currentOffset, pageSize, obj) {
753
+ if (paginationType !== 'PageNumber')
754
+ return { HasMore: false };
755
+ const target = obj ?? this.ScopedObject();
756
+ const envelope = target ? this.ObjectConfig(target)?.pagination?.envelope : undefined;
757
+ if (!envelope)
758
+ return { HasMore: false };
759
+ const container = envelope.container ? this.AsObject(this.ReadPath(rawBody, envelope.container.split('.'))) : this.AsObject(rawBody);
760
+ if (!container)
761
+ return { HasMore: false };
762
+ const page = this.FiniteNumber(envelope.currentPageKey ? container[envelope.currentPageKey] : undefined) ?? currentPage;
763
+ const totalPages = this.FiniteNumber(envelope.totalPagesKey ? container[envelope.totalPagesKey] : undefined);
764
+ const totalRecords = this.FiniteNumber(envelope.totalRecordsKey ? container[envelope.totalRecordsKey] : undefined);
765
+ if (totalPages != null) {
766
+ const hasMore = page < totalPages;
767
+ return { HasMore: hasMore, NextPage: hasMore ? page + 1 : undefined, TotalRecords: totalRecords ?? undefined };
768
+ }
769
+ if (totalRecords != null && pageSize > 0) {
770
+ const hasMore = page * pageSize < totalRecords;
771
+ return { HasMore: hasMore, NextPage: hasMore ? page + 1 : undefined, TotalRecords: totalRecords };
772
+ }
773
+ return { HasMore: false };
774
+ }
775
+ /**
776
+ * Emits ONLY the page parameter the object's metadata proves
777
+ * (`Configuration.pagination.paramName`). The base class's default would append `page=` AND
778
+ * `pageSize=`; Cadmium's vendor table documents the page-number parameter and NO page-size
779
+ * parameter at all, so sending one would be an invented name on the wire.
780
+ */
781
+ BuildPaginatedURL(basePath, obj, page, _offset, _cursor, _effectivePageSize) {
782
+ if (obj.PaginationType !== 'PageNumber')
783
+ return basePath;
784
+ const paramName = this.ObjectConfig(obj)?.pagination?.paramName;
785
+ if (!paramName)
786
+ return basePath;
787
+ const separator = basePath.includes('?') ? '&' : '?';
788
+ return `${basePath}${separator}${encodeURIComponent(paramName)}=${page}`;
789
+ }
790
+ /**
791
+ * PER-OBJECT, multi-host. Resolution order, all of it metadata:
792
+ * 1. the object's own `Configuration.baseUrl`, when a build ever declares one;
793
+ * 2. `Integration.Configuration.BaseURLsByFamily` keyed by the object's `Configuration.family`
794
+ * (falling back to `IntegrationObject.Category`, which carries the same family tag);
795
+ * 3. the object's `Configuration.absoluteEndpoint` with its own declared `APIPath` suffix
796
+ * removed — the last resort that still rescues a family with no table entry.
797
+ *
798
+ * 'asset' and 'eventscribe-web' both key this table and both must stay: they resolve to the same
799
+ * host TODAY, which is data, not a licence to collapse the tags in code. There is no default and
800
+ * no baked host — a family with no resolvable base URL raises, it does not silently pick one.
801
+ */
802
+ GetBaseURL(companyIntegration, _auth, objectName) {
803
+ const name = objectName ?? this.scope.getStore()?.ObjectName;
804
+ if (!name) {
805
+ throw new Error('[eventscribe] GetBaseURL was called with no object in scope. This vendor has five object ' +
806
+ 'families across three hosts, so a base URL cannot be resolved without knowing which object the ' +
807
+ 'request is for.');
808
+ }
809
+ // A single-origin override is a PER-CONNECTION fact — this connection points at a sandbox,
810
+ // a proxy, or a mock standing in for all three Cadmium hosts, while another connection on the
811
+ // same Integration still talks to production. So CompanyIntegration.Configuration is checked
812
+ // FIRST and the Integration-level value is only the fallback. (Reading solely the Integration
813
+ // row is why an earlier attempt at this override never fired: harnesses patch the CONNECTION.)
814
+ const perConnection = this.SingleOriginOverride(companyIntegration.Configuration);
815
+ if (perConnection)
816
+ return this.TrimTrailingSlash(perConnection);
817
+ return this.BaseURLForObject(this.GetCachedObject(companyIntegration.IntegrationID, name));
818
+ }
819
+ /** `BaseURL` off a connection's Configuration JSON, when present and non-empty. */
820
+ SingleOriginOverride(configurationJSON) {
821
+ const parsed = this.ParseJSONObject(configurationJSON ?? null);
822
+ const raw = parsed && typeof parsed === 'object' ? parsed.BaseURL : null;
823
+ return typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : null;
824
+ }
825
+ /** The per-object base URL resolution described on {@link GetBaseURL}. */
826
+ BaseURLForObject(obj) {
827
+ // Single-origin override wins over EVERYTHING, including a per-object baseUrl. When one host
828
+ // is standing in for all five Cadmium hosts — a mock server, a sandbox, a proxy — it must
829
+ // capture every request, or the objects carrying their own absolute baseUrl silently escape
830
+ // to the real internet while the rest are redirected. That split is exactly how a mock-mode
831
+ // run lands 0 rows and still looks like it ran.
832
+ const override = this.IntegrationConfig()?.BaseURL;
833
+ if (override)
834
+ return this.TrimTrailingSlash(override);
835
+ const cfg = this.ObjectConfig(obj);
836
+ if (cfg?.baseUrl)
837
+ return this.TrimTrailingSlash(cfg.baseUrl);
838
+ const family = cfg?.family ?? obj.Category ?? null;
839
+ const table = this.IntegrationConfig()?.BaseURLsByFamily;
840
+ if (family && table) {
841
+ const hit = table.find(e => e.family === family);
842
+ if (hit?.baseUrl)
843
+ return this.TrimTrailingSlash(hit.baseUrl);
844
+ }
845
+ const absolute = cfg?.absoluteEndpoint;
846
+ if (absolute) {
847
+ const path = obj.APIPath.startsWith('/') ? obj.APIPath : `/${obj.APIPath}`;
848
+ if (absolute.endsWith(path))
849
+ return this.TrimTrailingSlash(absolute.slice(0, absolute.length - path.length));
850
+ }
851
+ throw new Error(`[eventscribe] No base URL resolves for object "${obj.Name}" (family "${family ?? 'unset'}"). ` +
852
+ 'Declare it on the object\'s Configuration.baseUrl/absoluteEndpoint, or add the family to ' +
853
+ 'Integration.Configuration.BaseURLsByFamily. This connector never falls back to a baked host.');
854
+ }
855
+ // ── Write surface ─────────────────────────────────────────────────────────
856
+ //
857
+ // CreateRecord / UpdateRecord / DeleteRecord below are NOT re-implementations. Each is a THIN
858
+ // wrapper whose only job is to establish the per-call object scope that the multi-host
859
+ // `GetBaseURL` (and the per-object array-body decision) needs; the body of the operation is the
860
+ // base class's generic per-operation dispatch, reading CreateAPIPath/CreateMethod/CreateBodyShape/
861
+ // CreateBodyKey/CreateIDLocation, Update*, Delete* straight off the IntegrationObject row.
862
+ // `GetRecord` is the ONE read the generic path cannot express here — see its own note.
863
+ /** Scope-only wrapper; the create itself is the base class's metadata-driven generic dispatch. */
864
+ async CreateRecord(ctx) {
865
+ return this.scope.run(this.ScopeFor(ctx.CompanyIntegration, ctx.ObjectName, 'create'), () => super.CreateRecord(ctx));
866
+ }
867
+ /** Scope-only wrapper; the update itself is the base class's metadata-driven generic dispatch. */
868
+ async UpdateRecord(ctx) {
869
+ return this.scope.run(this.ScopeFor(ctx.CompanyIntegration, ctx.ObjectName, 'update'), () => super.UpdateRecord(ctx));
870
+ }
871
+ /**
872
+ * Scope-only wrapper around the base class's metadata-driven generic dispatch, PLUS one refusal:
873
+ * a delete whose declared request carries NO record identifier never goes on the wire. See
874
+ * {@link UnidentifiedDeleteGuard} — this is a safety gate, not a re-implementation.
875
+ */
876
+ async DeleteRecord(ctx) {
877
+ const refusal = this.UnidentifiedDeleteGuard(ctx);
878
+ if (refusal)
879
+ return refusal;
880
+ return this.scope.run(this.ScopeFor(ctx.CompanyIntegration, ctx.ObjectName, 'delete'), () => super.DeleteRecord(ctx));
881
+ }
882
+ /**
883
+ * Refuses a DESTRUCTIVE request that cannot name the record it is destroying.
884
+ *
885
+ * The base's generic delete substitutes the external id into the path ONLY when
886
+ * `DeleteIDLocation = 'path'` and the path carries an `{ID}` placeholder, and it sends NO body at
887
+ * all. Some of this vendor's delete-adjacent operations declare `DeleteIDLocation = 'n/a'` with
888
+ * `deleteOperation.idParam = null` because — per the frozen contract's own gap list — "the op
889
+ * documents no ID parameter at all in its parameters array". Firing that path verbatim would put
890
+ * an UNIDENTIFIED delete on a live event with nothing but the API key, the event scope and the
891
+ * Method name. Best case it 400s; worst case the vendor interprets it broadly. Neither is a risk
892
+ * worth taking to make a capability flag look satisfied.
893
+ *
894
+ * So this returns a FAILED CRUDResult naming the exact missing fact, rather than either (a) firing
895
+ * blind or (b) inventing a query-parameter name the vendor never documented — which would be the
896
+ * connector silently working around a metadata gap. Objects whose delete DOES carry an identifier
897
+ * (`...&AccountID={ID}` with `DeleteIDLocation = 'path'`) are untouched and ride the generic path.
898
+ */
899
+ UnidentifiedDeleteGuard(ctx) {
900
+ let obj;
901
+ try {
902
+ obj = this.GetCachedObject(ctx.CompanyIntegration.IntegrationID, ctx.ObjectName);
903
+ }
904
+ catch {
905
+ return null; // no metadata to judge by — let the base report the real configuration error
906
+ }
907
+ const path = obj.DeleteAPIPath;
908
+ if (!path)
909
+ return null; // the base already refuses an unconfigured delete, with its own message
910
+ const substitutes = obj.DeleteIDLocation === 'path' && /\{(ID|id|ExternalID)\}/.test(path);
911
+ if (substitutes)
912
+ return null;
913
+ const cfg = this.ObjectConfig(obj);
914
+ const idParam = cfg?.deleteOperation?.idParam;
915
+ if (idParam)
916
+ return null; // an identifier IS declared; the request can name its target
917
+ return {
918
+ Success: false,
919
+ // 0 = no request was made. There is no HTTP status to report because nothing was sent —
920
+ // reporting a real code here would misrepresent a refusal as a vendor rejection.
921
+ StatusCode: 0,
922
+ ErrorMessage: `[eventscribe] Refusing to delete "${ctx.ObjectName}" (${ctx.ExternalID}): its declared delete ` +
923
+ `operation carries no record identifier — DeleteIDLocation is "${obj.DeleteIDLocation ?? 'unset'}", ` +
924
+ 'the declared path has no {ID} placeholder, and Configuration.deleteOperation.idParam is null ' +
925
+ '(the vendor documents no ID parameter for this operation). Sending it would be an UNIDENTIFIED ' +
926
+ 'destructive request. Fix the IntegrationObject Delete* columns upstream — this connector will ' +
927
+ 'not invent a parameter name for a destructive call.',
928
+ };
929
+ }
930
+ /**
931
+ * THE SECOND GENUINELY IDIOSYNCRATIC PATH. This is the one read the base class cannot express on an
932
+ * RPC-over-querystring API: its generic `GetRecord` reuses `UpdateAPIPath` as the get-one path
933
+ * ("typically the same as the get-one path" — true of resource-oriented REST, where `/accounts/{id}`
934
+ * is both). Here `UpdateAPIPath` is `...?Method=addUpdateAccount&AccountID={ID}`, so the generic path
935
+ * would send a GET whose `Method` names a WRITE operation. `Configuration.ReadContract` is explicit
936
+ * that "the Method query param IS the routing/dispatch mechanism" — the verb does not disambiguate
937
+ * it — so that request is an upsert dispatched with no body, aimed at a live event. Refusing to build
938
+ * it is the same judgement as {@link UnidentifiedDeleteGuard}.
939
+ *
940
+ * Instead the read goes through the object's DECLARED READ DOOR ({@link DoorOperationFor}) with the
941
+ * record key on the query string under its DECLARED parameter name ({@link RecordKeyParamFor}).
942
+ * Everything is metadata; nothing is inferred from the verb or invented from a naming convention.
943
+ * A throw here is safe and preferred over a wrong request: the engine's only caller treats a failed
944
+ * re-read as "proceed with the full attribute set", so a refusal degrades to prior behaviour.
945
+ */
946
+ async GetRecord(ctx) {
947
+ const companyIntegration = ctx.CompanyIntegration;
948
+ const contextUser = ctx.ContextUser;
949
+ const obj = this.GetCachedObject(companyIntegration.IntegrationID, ctx.ObjectName);
950
+ const cfg = this.ObjectConfig(obj);
951
+ const wireGate = this.WireFormatGate(obj, cfg);
952
+ if (wireGate)
953
+ throw new Error(wireGate.Message);
954
+ if (this.DepthOf(cfg) !== 0) {
955
+ throw new Error(`[eventscribe] "${ctx.ObjectName}" has no read door of its own — its metadata declares ` +
956
+ `accessPath.depth ${this.DepthOf(cfg)}, i.e. its records arrive nested inside another ` +
957
+ "object's response. There is no single-record read for it; re-read the door object instead.");
958
+ }
959
+ const door = this.DoorOperationFor(obj, cfg);
960
+ if (!door) {
961
+ throw new Error(`[eventscribe] No read operation is declared for "${ctx.ObjectName}" ` +
962
+ '(Configuration.accessPath.doorOperation / dispatch.methodValue / DefaultQueryParams). ' +
963
+ 'On this RPC-over-querystring API a read cannot be dispatched without one.');
964
+ }
965
+ const keyParam = this.RecordKeyParamFor(obj, cfg);
966
+ if (!keyParam) {
967
+ throw new Error(`[eventscribe] Cannot read one "${ctx.ObjectName}" by id: no record-key parameter is declared ` +
968
+ '(Configuration.writeOperation.idParam / deleteOperation.idParam, and the object declares no ' +
969
+ 'single primary key to fall back on). This connector will not guess a query-parameter name.');
970
+ }
971
+ return this.scope.run(this.ScopeFor(companyIntegration, ctx.ObjectName, 'get'), async () => {
972
+ const auth = await this.Authenticate(companyIntegration, contextUser);
973
+ const doorURL = this.DoorURL(companyIntegration, auth, obj, door);
974
+ const url = `${doorURL}${doorURL.includes('?') ? '&' : '?'}${encodeURIComponent(keyParam)}=${encodeURIComponent(ctx.ExternalID)}`;
975
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', this.BuildHeaders(auth));
976
+ if (response.Status === 404)
977
+ return null;
978
+ if (response.Status < 200 || response.Status >= 300)
979
+ throw this.ErrorFor(response, url);
980
+ const rows = this.NormalizeResponse(response.Body, obj.ResponseDataKey);
981
+ if (rows.length === 0)
982
+ return null;
983
+ const fields = this.GetCachedFields(obj.ID);
984
+ return this.ToEventscribeRecord(rows[0], ctx.ObjectName, this.PrimaryKeyNames(fields), obj);
985
+ });
986
+ }
987
+ /**
988
+ * The query-parameter name that names ONE record on a read, in metadata order: the operation's own
989
+ * declared `idParam` first, then the object's single declared primary key. A COMPOSITE key returns
990
+ * null — this vendor documents no multi-key single-record door, and splitting one across invented
991
+ * parameter names would be a fabrication. So does an object with no declared key at all: the frozen
992
+ * contract withdrew seven weakly-evidenced keys, and its no-identity path forbids substituting a guess.
993
+ */
994
+ RecordKeyParamFor(obj, cfg) {
995
+ const declared = cfg?.writeOperation?.idParam ?? cfg?.deleteOperation?.idParam;
996
+ if (declared)
997
+ return declared;
998
+ const pks = this.PrimaryKeyNames(this.GetCachedFields(obj.ID));
999
+ return pks.length === 1 ? pks[0] : null;
1000
+ }
1001
+ /**
1002
+ * Wraps the generic flat body in a ONE-ELEMENT ARRAY for the operations whose metadata declares the
1003
+ * array-body convention. `Configuration.BatchSemantics` is explicit: "JSON array in the raw POST
1004
+ * body (single-object writes still require wrapping in a one-element array)". Sending the bare
1005
+ * object instead is a malformed request for those operations. Everything else keeps the base
1006
+ * class's shape untouched — the decision is per-operation and read from metadata (see
1007
+ * {@link UsesArrayBody}), never a list of vendor operation names written into this file.
1008
+ */
1009
+ BuildOperationBody(attributes, bodyShape, bodyKey) {
1010
+ const body = super.BuildOperationBody(attributes, bodyShape, bodyKey);
1011
+ const store = this.scope.getStore();
1012
+ if (!store || store.Verb === 'read' || store.Verb === 'get')
1013
+ return body;
1014
+ return this.UsesArrayBody(store.IntegrationID, store.ObjectName, store.Verb) ? [body] : body;
1015
+ }
1016
+ /**
1017
+ * Reads the new record's id from the vendor's response. The base helper only knows the generic
1018
+ * `id`/`ID` names; Cadmium returns the record's OWN key (Booth's `createIDBasis`: "the operation's
1019
+ * own sample response contains the record key 'BoothID'"), and for an array-body write the response
1020
+ * is an ARRAY of per-record results. Candidate names come from the object's declared
1021
+ * `writeOperation.idParam` and then its declared primary-key columns — from metadata, in order.
1022
+ * `IDLocation = 'n/a'` means the vendor documents NO id in the response; that returns undefined
1023
+ * rather than reaching for a field that was never promised.
1024
+ */
1025
+ ExtractIDFromResponse(response, idLocation) {
1026
+ if (!idLocation || idLocation === 'body' || idLocation === 'n/a') {
1027
+ const first = Array.isArray(response.Body) ? this.AsObject(response.Body[0]) : this.AsObject(response.Body);
1028
+ if (first) {
1029
+ for (const name of this.WriteIDFieldNames()) {
1030
+ const value = first[name];
1031
+ if (typeof value === 'string' || typeof value === 'number')
1032
+ return String(value);
1033
+ }
1034
+ }
1035
+ }
1036
+ // 'n/a' is the metadata's statement that the vendor promises NO id on this response
1037
+ // (Account's `createIDBasis`: "the created record must be re-read by its natural key"). The scan
1038
+ // above is opportunistic — if the vendor did not return the record's OWN declared key, this
1039
+ // stops here rather than falling through to the base helper's generic `id`/`ID` guesses, which
1040
+ // this vendor never documented. The base then fails the create LOUDLY, which is correct: an
1041
+ // untrackable create must not be reported green.
1042
+ if (idLocation === 'n/a')
1043
+ return undefined;
1044
+ return super.ExtractIDFromResponse(response, idLocation);
1045
+ }
1046
+ /** Reads the vendor's own message out of the documented `{"error": ...}` envelope. */
1047
+ ExtractErrorMessage(response) {
1048
+ return this.VendorMessage(response.Body) ?? super.ExtractErrorMessage(response);
1049
+ }
1050
+ /**
1051
+ * THE ONE GENUINELY IDIOSYNCRATIC WRITE PATH. `Configuration.BatchSemantics.addUpdateAccount`:
1052
+ * "non-atomic -- each record in the array is processed INDEPENDENTLY ... If ANY record in the batch
1053
+ * fails, the overall HTTP response status is 400 -- but valid records in the SAME request are still
1054
+ * created/updated. Callers must inspect the individual per-record results in the response body".
1055
+ *
1056
+ * So this sends ONE request carrying the whole array and then reads the PER-RECORD results out of
1057
+ * the body — a 400 is not treated as total failure. When the per-record results cannot be located
1058
+ * positionally, it degrades CONSERVATIVELY: a 2xx reports success, a non-2xx reports failure for
1059
+ * every record with the vendor's message, and no record is ever claimed successful on a guess
1060
+ * (the `addUpdate*` operations are upserts, so a conservative re-push is idempotent).
1061
+ */
1062
+ async BatchCreateRecords(ctxs) {
1063
+ return this.RunArrayBodyBatch(ctxs, 'create', c => this.CreateRecord(c));
1064
+ }
1065
+ /**
1066
+ * Same array-body batch, for the `addUpdate*` upserts on the update side — but ONLY when the
1067
+ * object's declared `UpdateAPIPath` carries no `{ID}` placeholder. Every current object declares
1068
+ * one (`...&AccountID={ID}`), which is the SINGLE-record URL shape, and stripping it to force a
1069
+ * batch would be inventing a request. Those fall back to the per-record path: correctness first,
1070
+ * throughput second.
1071
+ */
1072
+ async BatchUpdateRecords(ctxs) {
1073
+ return this.RunArrayBodyBatch(ctxs, 'update', c => this.UpdateRecord(c));
1074
+ }
1075
+ /** Groups by object, batches the eligible groups, and routes the rest through the single-record path. */
1076
+ async RunArrayBodyBatch(ctxs, verb, single) {
1077
+ const results = new Array(ctxs.length);
1078
+ const groups = new Map();
1079
+ for (let i = 0; i < ctxs.length; i++) {
1080
+ const list = groups.get(ctxs[i].ObjectName);
1081
+ if (list)
1082
+ list.push(i);
1083
+ else
1084
+ groups.set(ctxs[i].ObjectName, [i]);
1085
+ }
1086
+ for (const [objectName, indexes] of groups) {
1087
+ const companyIntegration = ctxs[indexes[0]].CompanyIntegration;
1088
+ const contextUser = ctxs[indexes[0]].ContextUser;
1089
+ const obj = this.GetCachedObject(companyIntegration.IntegrationID, objectName);
1090
+ const path = verb === 'create' ? obj.CreateAPIPath : obj.UpdateAPIPath;
1091
+ const method = verb === 'create' ? obj.CreateMethod : obj.UpdateMethod;
1092
+ const idLocation = verb === 'create' ? obj.CreateIDLocation : obj.UpdateIDLocation;
1093
+ const eligible = path != null
1094
+ && method != null
1095
+ && !/\{(ID|id|ExternalID)\}/.test(path)
1096
+ && this.UsesArrayBody(companyIntegration.IntegrationID, objectName, verb);
1097
+ if (!eligible) {
1098
+ for (const i of indexes)
1099
+ results[i] = await single(ctxs[i]);
1100
+ continue;
1101
+ }
1102
+ const bodies = indexes.map(i => ctxs[i].Attributes);
1103
+ const auth = await this.Authenticate(companyIntegration, contextUser);
1104
+ const url = this.JoinURL(this.GetBaseURL(companyIntegration, auth, objectName), path);
1105
+ // Same per-call object scope the single-record path establishes, so the per-record id is read
1106
+ // with the object's OWN declared key names rather than a generic guess.
1107
+ const scope = this.ScopeFor(companyIntegration, objectName, verb);
1108
+ const { response, outcomes } = await this.scope.run(scope, async () => {
1109
+ const sent = await this.SendRequest(auth, url, method, this.BuildHeaders(auth), bodies);
1110
+ return { response: sent, outcomes: this.ReadPerRecordOutcomes(sent, bodies.length, idLocation) };
1111
+ });
1112
+ for (let k = 0; k < indexes.length; k++) {
1113
+ const outcome = outcomes[k];
1114
+ results[indexes[k]] = outcome.Success
1115
+ ? { Success: true, StatusCode: response.Status, ExternalID: outcome.ExternalID }
1116
+ : { Success: false, StatusCode: response.Status, ErrorMessage: outcome.ErrorMessage };
1117
+ }
1118
+ }
1119
+ return results;
1120
+ }
1121
+ /**
1122
+ * Reads the per-record results out of a non-atomic array-body response. The vendor documents that
1123
+ * they exist and must be inspected, but never prints their exact schema, so this locates them
1124
+ * POSITIONALLY: the response array (or the first array-valued property of the response object)
1125
+ * whose length matches the request array. Each item fails when it carries the documented error
1126
+ * envelope; otherwise it succeeded — even under an overall HTTP 400, which is precisely the
1127
+ * partial-success case. No positional array ⇒ the conservative all-or-nothing verdict.
1128
+ */
1129
+ ReadPerRecordOutcomes(response, expected, idLocation) {
1130
+ const items = this.LocatePositionalResults(response.Body, expected);
1131
+ const ok = response.Status >= 200 && response.Status < 300;
1132
+ if (!items) {
1133
+ const message = this.VendorMessage(response.Body)
1134
+ ?? `HTTP ${response.Status} on batch write; the vendor's per-record results were not present in the response body`;
1135
+ return Array.from({ length: expected }, () => (ok ? { Success: true } : { Success: false, ErrorMessage: message }));
1136
+ }
1137
+ return items.map((item) => {
1138
+ const asObject = this.AsObject(item);
1139
+ const error = this.VendorMessage(item);
1140
+ if (error != null)
1141
+ return { Success: false, ErrorMessage: error };
1142
+ const id = asObject
1143
+ ? this.ExtractIDFromResponse({ Status: response.Status, Body: asObject, Headers: response.Headers }, idLocation)
1144
+ : undefined;
1145
+ return { Success: true, ExternalID: id };
1146
+ });
1147
+ }
1148
+ /** The response array whose length matches the request array, at the root or one level down. */
1149
+ LocatePositionalResults(body, expected) {
1150
+ if (Array.isArray(body))
1151
+ return body.length === expected ? body : null;
1152
+ const asObject = this.AsObject(body);
1153
+ if (!asObject)
1154
+ return null;
1155
+ for (const value of Object.values(asObject)) {
1156
+ if (Array.isArray(value) && value.length === expected)
1157
+ return value;
1158
+ }
1159
+ return null;
1160
+ }
1161
+ /**
1162
+ * Whether the object's write/delete operation takes a JSON ARRAY body, decided ENTIRELY from
1163
+ * metadata: a structural `requestShape` on the operation, a structural
1164
+ * `Integration.Configuration.BatchSemantics[<operationId>]` entry, or the vendor's own
1165
+ * cross-operation convention statement — which is matched against the operation id the OBJECT's
1166
+ * metadata supplies, so no vendor operation name is ever written into this file.
1167
+ */
1168
+ UsesArrayBody(integrationID, objectName, verb) {
1169
+ let cfg;
1170
+ try {
1171
+ cfg = this.ObjectConfig(this.GetCachedObject(integrationID, objectName));
1172
+ }
1173
+ catch {
1174
+ return false;
1175
+ }
1176
+ const operation = verb === 'delete' ? cfg?.deleteOperation : cfg?.writeOperation;
1177
+ const operationID = operation?.operationId;
1178
+ if (!operationID)
1179
+ return false;
1180
+ if (operation?.requestShape && /array/i.test(operation.requestShape))
1181
+ return true;
1182
+ const semantics = this.IntegrationConfig()?.BatchSemantics;
1183
+ if (!semantics)
1184
+ return false;
1185
+ const entry = this.AsObject(semantics[operationID]);
1186
+ if (entry) {
1187
+ const shape = entry.requestShape;
1188
+ if (typeof shape !== 'string' || /array/i.test(shape))
1189
+ return true;
1190
+ }
1191
+ for (const value of Object.values(semantics)) {
1192
+ const convention = this.AsObject(value);
1193
+ const description = convention?.description;
1194
+ if (typeof description === 'string' && description.includes(operationID))
1195
+ return true;
1196
+ }
1197
+ return false;
1198
+ }
1199
+ /** Candidate id field names for the CURRENT scope's object, in metadata order. Never a guessed name. */
1200
+ WriteIDFieldNames() {
1201
+ const store = this.scope.getStore();
1202
+ if (!store)
1203
+ return [];
1204
+ let obj;
1205
+ try {
1206
+ obj = this.GetCachedObject(store.IntegrationID, store.ObjectName);
1207
+ }
1208
+ catch {
1209
+ return [];
1210
+ }
1211
+ const cfg = this.ObjectConfig(obj);
1212
+ const names = [];
1213
+ const declared = store.Verb === 'delete' ? cfg?.deleteOperation?.idParam : cfg?.writeOperation?.idParam;
1214
+ if (declared)
1215
+ names.push(declared);
1216
+ for (const name of this.PrimaryKeyNames(this.GetCachedFields(obj.ID))) {
1217
+ if (!names.includes(name))
1218
+ names.push(name);
1219
+ }
1220
+ return names;
1221
+ }
1222
+ // ── Rate limiting (vendor-documented, per METHOD) ─────────────────────────
1223
+ /**
1224
+ * Honours the vendor's documented spacing before every request. The window comes from metadata —
1225
+ * `Integration.Configuration.RateLimits.standard` for the vendor-wide allowance, its `overrides`
1226
+ * (and any object whose own `Configuration.rateLimit` is scoped `object-override`) for the two
1227
+ * documented heavy methods that require 60 s between calls. Keyed by `host|MethodValue`, because
1228
+ * on this RPC API a "method" is the operation, not the URL path. No window in metadata ⇒ no pacing
1229
+ * invented here; the engine's own adaptive limiter still applies.
1230
+ *
1231
+ * SCOPED TO THE HOSTS THE METADATA DECLARES ({@link VendorHosts}). The documented allowance is a
1232
+ * property of Cadmium's OWN service, identified in metadata by host. When a connection is pointed
1233
+ * somewhere else — an operator's gateway, a staging or replay endpoint — this connector holds NO
1234
+ * documented allowance for that host, and imposing a 60-second sleep on a service whose real policy
1235
+ * is unknown is an invented number, not a safe default. Those requests are governed by the engine's
1236
+ * adaptive limiter and its 429 handling instead. Declares no host at all ⇒ everything is paced.
1237
+ */
1238
+ async PaceRequest(url) {
1239
+ const host = this.HostOf(url).toLowerCase();
1240
+ const declaredHosts = this.VendorHosts();
1241
+ if (declaredHosts.size > 0 && !declaredHosts.has(host))
1242
+ return;
1243
+ const methodValue = this.MethodValueFromURL(url);
1244
+ const windowMs = this.RateWindowFor(methodValue);
1245
+ if (!Number.isFinite(windowMs) || windowMs <= 0)
1246
+ return;
1247
+ const key = `${host}|${methodValue ?? ''}`;
1248
+ const now = Date.now();
1249
+ const earliest = this.nextAllowedAt.get(key) ?? 0;
1250
+ const wait = Math.max(0, earliest - now);
1251
+ this.nextAllowedAt.set(key, Math.max(now, earliest) + windowMs);
1252
+ if (wait > 0)
1253
+ await this.Sleep(wait);
1254
+ }
1255
+ /** The documented spacing for one operation: per-method override first, vendor-wide standard second. */
1256
+ RateWindowFor(methodValue) {
1257
+ const config = this.IntegrationConfig();
1258
+ const limits = config?.RateLimits;
1259
+ if (methodValue) {
1260
+ for (const override of limits?.overrides ?? []) {
1261
+ if (override.methods?.includes(methodValue) && Number.isFinite(override.windowMs)) {
1262
+ return override.windowMs;
1263
+ }
1264
+ }
1265
+ const perObject = this.ObjectRateOverrideFor(methodValue);
1266
+ if (perObject != null)
1267
+ return perObject;
1268
+ }
1269
+ return Number.isFinite(limits?.standard?.windowMs) ? limits.standard.windowMs : 0;
1270
+ }
1271
+ /** A per-object `Configuration.rateLimit` explicitly scoped `object-override`, matched by Method value. */
1272
+ ObjectRateOverrideFor(methodValue) {
1273
+ const integrationID = this.tryGetIntegrationID();
1274
+ if (!integrationID)
1275
+ return null;
1276
+ for (const obj of this.getCachedObjects(integrationID)) {
1277
+ const cfg = this.ObjectConfig(obj);
1278
+ const rate = cfg?.rateLimit;
1279
+ if (!rate || rate.scope !== 'object-override' || !Number.isFinite(rate.windowMs))
1280
+ continue;
1281
+ if (this.DoorOperationFor(obj, cfg) === methodValue)
1282
+ return rate.windowMs;
1283
+ }
1284
+ return null;
1285
+ }
1286
+ /**
1287
+ * The hosts the METADATA declares for this vendor: every `BaseURLsByFamily` entry plus every
1288
+ * object's own `Configuration.absoluteEndpoint` (which is what carries the out-of-scope families
1289
+ * whose hosts the family table deliberately omits). This is the set the vendor's documented
1290
+ * rate-limit applies to — see {@link PaceRequest}. Never a literal: an empty set means the
1291
+ * metadata named no host, and the conservative "pace everything" branch takes over.
1292
+ */
1293
+ VendorHosts() {
1294
+ const hosts = new Set();
1295
+ for (const entry of this.IntegrationConfig()?.BaseURLsByFamily ?? []) {
1296
+ const host = this.HostOf(entry.baseUrl).toLowerCase();
1297
+ if (host.length > 0)
1298
+ hosts.add(host);
1299
+ }
1300
+ const integrationID = this.tryGetIntegrationID();
1301
+ if (integrationID) {
1302
+ for (const obj of this.getCachedObjects(integrationID)) {
1303
+ const endpoint = this.ObjectConfig(obj)?.absoluteEndpoint;
1304
+ if (!endpoint)
1305
+ continue;
1306
+ const host = this.HostOf(endpoint).toLowerCase();
1307
+ if (host.length > 0)
1308
+ hosts.add(host);
1309
+ }
1310
+ }
1311
+ return hosts;
1312
+ }
1313
+ /** Sleeps. Isolated so tests can assert the pacing decision without waiting for it. */
1314
+ async Sleep(ms) {
1315
+ if (ms <= 0)
1316
+ return;
1317
+ await new Promise(resolve => setTimeout(resolve, ms));
1318
+ }
1319
+ // ── URL + credential assembly ─────────────────────────────────────────────
1320
+ /**
1321
+ * Adds the credential query parameters. Their NAMES come from
1322
+ * `Integration.Configuration.AuthCredentialParamName` ("APIKey") and
1323
+ * `Configuration.AuthMultiTenantParam.name` ("eID"); if the metadata does not name the credential
1324
+ * parameter, this raises rather than guessing a name onto the wire. `eID` is added only when the
1325
+ * connection actually carries an event id, and an existing value in the URL is never overwritten.
1326
+ */
1327
+ WithCredentialParams(auth, url) {
1328
+ const config = this.IntegrationConfig();
1329
+ const keyParam = config?.AuthCredentialParamName;
1330
+ if (!keyParam) {
1331
+ throw new Error('[eventscribe] Integration.Configuration.AuthCredentialParamName is not set, so the name of the ' +
1332
+ 'credential query parameter is unknown. This connector will not guess a parameter name onto the ' +
1333
+ 'wire — push metadata/integrations/eventscribe first.');
1334
+ }
1335
+ const existing = this.QueryKeys(url);
1336
+ let out = url;
1337
+ if (!existing.has(keyParam.toLowerCase())) {
1338
+ out += `${out.includes('?') ? '&' : '?'}${encodeURIComponent(keyParam)}=${encodeURIComponent(auth.APIKey)}`;
1339
+ }
1340
+ const eventParam = config?.AuthMultiTenantParam?.name;
1341
+ if (eventParam && auth.EventID && !existing.has(eventParam.toLowerCase())) {
1342
+ out += `${out.includes('?') ? '&' : '?'}${encodeURIComponent(eventParam)}=${encodeURIComponent(auth.EventID)}`;
1343
+ }
1344
+ return out;
1345
+ }
1346
+ /**
1347
+ * Appends the DECLARED incremental-window parameters resolved by {@link WindowParamsFor} for the
1348
+ * read currently in scope. Absent scope, or an object with no declared window, is a no-op — so a
1349
+ * full-pull object's URL is byte-identical to what it was before. An existing value on the URL is
1350
+ * never overwritten.
1351
+ */
1352
+ WithWindowParams(url) {
1353
+ const window = this.scope.getStore()?.WindowParams;
1354
+ if (!window)
1355
+ return url;
1356
+ const existing = this.QueryKeys(url);
1357
+ let out = url;
1358
+ for (const [name, value] of Object.entries(window)) {
1359
+ if (existing.has(name.toLowerCase()))
1360
+ continue;
1361
+ out += `${out.includes('?') ? '&' : '?'}${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
1362
+ }
1363
+ return out;
1364
+ }
1365
+ /**
1366
+ * `<familyBase><APIPath>?<MethodParam>=<operation>` for one object's door.
1367
+ *
1368
+ * The base URL is resolved through {@link GetBaseURL} — NOT by calling {@link BaseURLForObject}
1369
+ * directly — so that EVERY request this connector makes goes through the ONE resolution seam.
1370
+ * `GetBaseURL` is the documented per-connection override point (an operator pointing a connection
1371
+ * at a gateway, a harness redirecting the origin); a path that reaches around it would obey the
1372
+ * declared vendor host while the rest of the connector obeyed the override, which is exactly the
1373
+ * kind of split-brain routing that sends half a sync to the wrong origin.
1374
+ */
1375
+ DoorURL(companyIntegration, auth, obj, operation) {
1376
+ const base = this.JoinURL(this.GetBaseURL(companyIntegration, auth, obj.Name), obj.APIPath);
1377
+ const param = this.MethodParamName(obj);
1378
+ if (!param) {
1379
+ throw new Error(`[eventscribe] No dispatch parameter name is declared for "${obj.Name}" ` +
1380
+ '(Integration.Configuration.ReadContract.methodParamName / Configuration.dispatch.methodParamName). ' +
1381
+ 'On this RPC-over-querystring API the operation cannot be selected without it.');
1382
+ }
1383
+ // The credential is deliberately absent here: SendRequest is the single place it touches the wire.
1384
+ return `${base}${base.includes('?') ? '&' : '?'}${encodeURIComponent(param)}=${encodeURIComponent(operation)}`;
1385
+ }
1386
+ /** The dispatch parameter name: the object's own declaration first, the integration contract second. */
1387
+ MethodParamName(obj) {
1388
+ return this.ObjectConfig(obj)?.dispatch?.methodParamName
1389
+ ?? this.IntegrationConfig()?.ReadContract?.methodParamName
1390
+ ?? null;
1391
+ }
1392
+ /** The Method value carried on a built URL, used only to key the vendor's per-method pacing. */
1393
+ MethodValueFromURL(url) {
1394
+ const param = this.IntegrationConfig()?.ReadContract?.methodParamName;
1395
+ if (!param)
1396
+ return null;
1397
+ try {
1398
+ return new URL(url).searchParams.get(param);
1399
+ }
1400
+ catch {
1401
+ return null;
1402
+ }
1403
+ }
1404
+ // ── Metadata routing helpers ──────────────────────────────────────────────
1405
+ /**
1406
+ * The operation that RETURNS this object's records. For a nested object that is the DOOR
1407
+ * (`accessPath.doorOperation`); for a directly-queryable object it is its own read method. The
1408
+ * declared access path wins over `dispatch.methodValue`, because the access path is the statement
1409
+ * about where the records actually come from.
1410
+ */
1411
+ DoorOperationFor(obj, cfg) {
1412
+ const fromAccessPath = cfg?.accessPath?.doorOperation;
1413
+ if (fromAccessPath)
1414
+ return fromAccessPath;
1415
+ const fromDispatch = cfg?.dispatch?.methodValue;
1416
+ if (fromDispatch)
1417
+ return fromDispatch;
1418
+ const param = this.MethodParamName(obj);
1419
+ const defaults = this.ParseJSONObject(obj.DefaultQueryParams);
1420
+ const fromDefaults = param && defaults ? defaults[param] : undefined;
1421
+ return typeof fromDefaults === 'string' && fromDefaults.length > 0 ? fromDefaults : null;
1422
+ }
1423
+ /** How deep this object sits under its door. 0 = directly queryable. */
1424
+ DepthOf(cfg) {
1425
+ const depth = cfg?.accessPath?.depth;
1426
+ return typeof depth === 'number' && Number.isFinite(depth) ? depth : 0;
1427
+ }
1428
+ /**
1429
+ * The response key carrying a nested object's rows: the explicit `nestedContainerKey`, else the
1430
+ * last segment of the declared `accessPath.nestingFieldPath` (`"Exhibitor → Booths[]"` → `Booths`).
1431
+ * Both are metadata; nothing is inferred from the payload.
1432
+ */
1433
+ NestedContainerKeyFor(cfg) {
1434
+ const explicit = cfg?.nestedContainerKey;
1435
+ if (explicit)
1436
+ return explicit;
1437
+ const path = cfg?.accessPath?.nestingFieldPath;
1438
+ if (!path)
1439
+ return null;
1440
+ const segments = path.split('→').map(s => s.trim()).filter(s => s.length > 0);
1441
+ const last = segments[segments.length - 1];
1442
+ if (!last)
1443
+ return null;
1444
+ const cleaned = last.replace(/\[\]$/, '').trim();
1445
+ return cleaned.length > 0 ? cleaned : null;
1446
+ }
1447
+ /** Parsed `Configuration` JSON for one IntegrationObject; malformed degrades to absent. */
1448
+ ObjectConfig(obj) {
1449
+ const parsed = this.ParseJSONObject(obj.Configuration);
1450
+ if (!parsed)
1451
+ return null;
1452
+ const result = ZObjectConfig.safeParse(parsed);
1453
+ return result.success ? result.data : null;
1454
+ }
1455
+ /** Parsed `Integration.Configuration` — the connector-wide vendor facts. */
1456
+ IntegrationConfig() {
1457
+ const parsed = this.ParseJSONObject(this.IntegrationConfigurationJSON());
1458
+ if (!parsed)
1459
+ return null;
1460
+ const result = ZIntegrationConfig.safeParse(parsed);
1461
+ return result.success ? result.data : null;
1462
+ }
1463
+ /** The raw `Integration.Configuration` string. Isolated so tests can supply it without the engine. */
1464
+ IntegrationConfigurationJSON() {
1465
+ try {
1466
+ return IntegrationEngineBase.Instance.GetIntegrationByName(this.IntegrationName)?.Configuration ?? null;
1467
+ }
1468
+ catch {
1469
+ return null;
1470
+ }
1471
+ }
1472
+ /** The object the current async call chain is serving, when one is in scope. */
1473
+ ScopedObject() {
1474
+ const store = this.scope.getStore();
1475
+ if (!store)
1476
+ return null;
1477
+ try {
1478
+ return this.GetCachedObject(store.IntegrationID, store.ObjectName);
1479
+ }
1480
+ catch {
1481
+ return null;
1482
+ }
1483
+ }
1484
+ /** The cached IntegrationObject by name, or null. Routes through the same seam every read path uses. */
1485
+ TryGetCachedObject(integrationID, objectName) {
1486
+ try {
1487
+ return this.GetCachedObject(integrationID, objectName);
1488
+ }
1489
+ catch {
1490
+ return null;
1491
+ }
1492
+ }
1493
+ /** ACTIVE objects for this integration; absent metadata degrades to an empty list, never a throw. */
1494
+ getCachedObjects(integrationID) {
1495
+ try {
1496
+ return IntegrationEngineBase.Instance.GetActiveIntegrationObjects(integrationID);
1497
+ }
1498
+ catch {
1499
+ return [];
1500
+ }
1501
+ }
1502
+ /** This integration's ID by its verbatim name, or null when metadata is not loaded. */
1503
+ tryGetIntegrationID() {
1504
+ try {
1505
+ return IntegrationEngineBase.Instance.GetIntegrationByName(this.IntegrationName)?.ID ?? null;
1506
+ }
1507
+ catch {
1508
+ return null;
1509
+ }
1510
+ }
1511
+ /** The per-call scope a generic CRUD verb runs inside. */
1512
+ ScopeFor(companyIntegration, objectName, verb) {
1513
+ return {
1514
+ IntegrationID: companyIntegration.IntegrationID,
1515
+ ObjectName: objectName,
1516
+ Verb: verb,
1517
+ };
1518
+ }
1519
+ // ── Record assembly ───────────────────────────────────────────────────────
1520
+ /**
1521
+ * Builds an `ExternalRecord` whose `Fields` is the COMPLETE source row — never a projection — so
1522
+ * the framework's custom-column capture can still see a per-tenant column this build never
1523
+ * declared. Composite keys join with `|`. When a declared key value is missing (several of this
1524
+ * vendor's nested leaves have only a WEAK, shape-derived key) the identity falls back to a content
1525
+ * hash: a soft key must never be able to REJECT a valid row.
1526
+ */
1527
+ ToEventscribeRecord(raw, objectType, pkFieldNames, obj) {
1528
+ const allPresent = pkFieldNames.length > 0
1529
+ && pkFieldNames.every(name => raw[name] != null && serializeKeyValue(raw[name]).length > 0);
1530
+ const externalID = allPresent
1531
+ ? pkFieldNames.map(name => serializeKeyValue(raw[name])).join('|')
1532
+ : computeContentHash(raw);
1533
+ const record = { ExternalID: externalID, ObjectType: objectType, Fields: raw };
1534
+ const watermarkField = obj.IncrementalWatermarkField;
1535
+ const watermark = watermarkField ? raw[watermarkField] : null;
1536
+ if (watermark != null) {
1537
+ const when = new Date(String(watermark));
1538
+ if (!Number.isNaN(when.getTime()))
1539
+ record.ModifiedAt = when;
1540
+ }
1541
+ return record;
1542
+ }
1543
+ /**
1544
+ * The DECLARED primary-key names in Sequence order — and NOTHING else. Deliberately does NOT use
1545
+ * the base class's synthetic `['ID']` fallback: the frozen contract WITHDREW seven weakly-evidenced
1546
+ * keys (they survive as ordinary nullable columns, and the `StableOrderingKey` columns that pointed
1547
+ * at them were nulled to match), and its no-identity path is explicit that a PK-less object must
1548
+ * make NO idempotent-identity claim and must NOT substitute a guessed alternative. Returning `['ID']`
1549
+ * here would be exactly that guess — and would silently start claiming identity the day a tenant's
1550
+ * payload happens to carry a column literally named `ID`. An empty list routes the object to the
1551
+ * content-hash identity in {@link ToEventscribeRecord}, i.e. the append/full-refresh path the
1552
+ * contract prescribes, where the engine's own hash idempotency does the deduplication.
1553
+ */
1554
+ PrimaryKeyNames(fields) {
1555
+ return fields.filter(f => f.IsPrimaryKey).sort((a, b) => a.Sequence - b.Sequence).map(f => f.Name);
1556
+ }
1557
+ // ── Credential resolution ─────────────────────────────────────────────────
1558
+ /** Credential record first, connection Configuration second. No inline crypto; nothing is logged. */
1559
+ async LoadCredentials(companyIntegration, contextUser) {
1560
+ let fromCredential = null;
1561
+ if (companyIntegration.CredentialID) {
1562
+ try {
1563
+ const md = new Metadata();
1564
+ const credential = await md.GetEntityObject('MJ: Credentials', contextUser);
1565
+ const loaded = await credential.Load(companyIntegration.CredentialID);
1566
+ if (loaded && credential.Values)
1567
+ fromCredential = this.ParseCredentialJSON(credential.Values);
1568
+ }
1569
+ catch {
1570
+ // A credential the connection cannot load is a configuration problem, not a crash: the
1571
+ // Configuration fallback still applies and Authenticate reports precisely what is missing.
1572
+ }
1573
+ }
1574
+ const fromConfig = this.ParseCredentialJSON(companyIntegration.Configuration);
1575
+ return {
1576
+ APIKey: fromCredential?.APIKey ?? fromConfig?.APIKey,
1577
+ EventID: fromCredential?.EventID ?? fromConfig?.EventID,
1578
+ };
1579
+ }
1580
+ /** Extracts the two credential values from a credential / Configuration JSON string. */
1581
+ ParseCredentialJSON(json) {
1582
+ const parsed = this.ParseJSONObject(json);
1583
+ if (!parsed)
1584
+ return null;
1585
+ return {
1586
+ APIKey: this.FirstString(parsed, ['APIKey', 'apiKey', 'ApiKey', 'api_key', 'apikey', 'key']),
1587
+ EventID: this.FirstString(parsed, ['eID', 'eId', 'EID', 'eventID', 'EventID', 'eventId', 'event_id']),
1588
+ };
1589
+ }
1590
+ // ── Error classification ──────────────────────────────────────────────────
1591
+ /**
1592
+ * Classifies from the vendor's own envelope AND the status. `Configuration.ErrorContract` documents
1593
+ * `{"error": "<human-readable message>"}` with 400 and 404 observed, and states it applies "across
1594
+ * API methods" — so a 2xx carrying that envelope is a FAILURE, which is exactly how a sync would
1595
+ * otherwise report zero rows and green at the same time. The one documented exception (404 with a
1596
+ * body of `[]`) is normalised to an empty success in {@link SendRequest} and never reaches here.
1597
+ */
1598
+ ErrorFor(response, url) {
1599
+ const vendorMessage = this.VendorMessage(response.Body);
1600
+ const classification = this.ClassifyEventscribeResponse(response.Status, vendorMessage);
1601
+ return new EventscribeAPIError(`[eventscribe] HTTP ${response.Status} (${classification.Reason}) from ${this.PathOf(url)}: ` +
1602
+ `${vendorMessage ?? 'no vendor message'}`, response.Status, response.Headers, classification, vendorMessage);
1603
+ }
1604
+ /** The status/envelope → structured verdict mapping, exposed so tests can assert it directly. */
1605
+ ClassifyEventscribeResponse(status, vendorMessage) {
1606
+ if (status === 429) {
1607
+ return { Code: 'RATE_LIMIT_EXCEEDED', Severity: 'Warning', Retryable: true, Reason: 'throttled' };
1608
+ }
1609
+ if (status >= 500) {
1610
+ return { Code: 'CONNECTOR_ERROR', Severity: 'Critical', Retryable: true, Reason: 'server-error' };
1611
+ }
1612
+ if (status === 401 || status === 403) {
1613
+ return { Code: 'CONFIGURATION_ERROR', Severity: 'Critical', Retryable: false, Reason: 'credential-rejected' };
1614
+ }
1615
+ if (status === 404) {
1616
+ return { Code: 'CONNECTOR_ERROR', Severity: 'Warning', Retryable: false, Reason: 'not-found' };
1617
+ }
1618
+ if (status === 400) {
1619
+ return { Code: 'VALIDATION_ERROR', Severity: 'Critical', Retryable: false, Reason: 'rejected-by-vendor' };
1620
+ }
1621
+ // A 2xx that carried the documented error envelope: a real failure wearing a success status.
1622
+ return {
1623
+ Code: vendorMessage != null ? 'CONNECTOR_ERROR' : 'UNKNOWN_ERROR',
1624
+ Severity: 'Critical',
1625
+ Retryable: false,
1626
+ Reason: vendorMessage != null ? 'error-envelope-on-success-status' : 'unclassified',
1627
+ };
1628
+ }
1629
+ /** The vendor's message from the documented envelope, or undefined when the body carries no error. */
1630
+ VendorMessage(body) {
1631
+ const asObject = this.AsObject(body);
1632
+ if (!asObject)
1633
+ return undefined;
1634
+ const error = asObject.error ?? asObject.Error;
1635
+ if (typeof error === 'string' && error.length > 0)
1636
+ return error;
1637
+ const nested = this.AsObject(error);
1638
+ if (nested && typeof nested.message === 'string' && nested.message.length > 0)
1639
+ return nested.message;
1640
+ if (nested)
1641
+ return JSON.stringify(nested);
1642
+ return undefined;
1643
+ }
1644
+ // ── Small utilities ───────────────────────────────────────────────────────
1645
+ /** Walks a dotted path into a parsed body. Returns undefined at the first missing segment. */
1646
+ ReadPath(source, path) {
1647
+ let cursor = source;
1648
+ for (const segment of path) {
1649
+ const asObject = this.AsObject(cursor);
1650
+ if (!asObject || !(segment in asObject))
1651
+ return undefined;
1652
+ cursor = asObject[segment];
1653
+ }
1654
+ return cursor;
1655
+ }
1656
+ /** Joins a base URL with an API path exactly as declared — no path is invented or normalised away. */
1657
+ JoinURL(baseURL, apiPath) {
1658
+ const base = this.TrimTrailingSlash(baseURL);
1659
+ const path = apiPath.startsWith('/') ? apiPath : `/${apiPath}`;
1660
+ return `${base}${path}`;
1661
+ }
1662
+ TrimTrailingSlash(value) {
1663
+ const trimmed = value.trim();
1664
+ return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed;
1665
+ }
1666
+ /** Lowercased query-parameter keys already present on a URL. */
1667
+ QueryKeys(url) {
1668
+ const out = new Set();
1669
+ const q = url.indexOf('?');
1670
+ if (q < 0)
1671
+ return out;
1672
+ for (const pair of url.slice(q + 1).split('&')) {
1673
+ const eq = pair.indexOf('=');
1674
+ const key = eq < 0 ? pair : pair.slice(0, eq);
1675
+ if (key.length > 0)
1676
+ out.add(decodeURIComponent(key).toLowerCase());
1677
+ }
1678
+ return out;
1679
+ }
1680
+ /** Host of a URL, for pacing keys. Falls back to the raw string when it is not parseable. */
1681
+ HostOf(url) {
1682
+ try {
1683
+ return new URL(url).host;
1684
+ }
1685
+ catch {
1686
+ return url;
1687
+ }
1688
+ }
1689
+ /** Path-only view of a URL, for messages that must never carry a query string or a credential. */
1690
+ PathOf(url) {
1691
+ try {
1692
+ return new URL(url).pathname;
1693
+ }
1694
+ catch {
1695
+ return url.split('?')[0];
1696
+ }
1697
+ }
1698
+ /** A narrowing cast to a plain object, or null. */
1699
+ AsObject(value) {
1700
+ return value != null && typeof value === 'object' && !Array.isArray(value)
1701
+ ? value
1702
+ : null;
1703
+ }
1704
+ /** First non-empty string value among `keys` on a parsed object. */
1705
+ FirstString(source, keys) {
1706
+ if (!source)
1707
+ return undefined;
1708
+ for (const key of keys) {
1709
+ const v = source[key];
1710
+ if (typeof v === 'string' && v.trim().length > 0)
1711
+ return v.trim();
1712
+ }
1713
+ return undefined;
1714
+ }
1715
+ /** A finite number from an arbitrary JSON value, or null. Vendor counters arrive as either type. */
1716
+ FiniteNumber(value) {
1717
+ const n = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN;
1718
+ return Number.isFinite(n) ? n : null;
1719
+ }
1720
+ /** Tolerant JSON-object parse; malformed configuration degrades to "absent" rather than crashing. */
1721
+ ParseJSONObject(json) {
1722
+ if (!json || json.trim().length === 0)
1723
+ return null;
1724
+ try {
1725
+ return this.AsObject(JSON.parse(json));
1726
+ }
1727
+ catch {
1728
+ return null;
1729
+ }
1730
+ }
1731
+ /** An error message safe to log: never carries credential bytes. */
1732
+ SafeMessage(err) {
1733
+ let raw = err instanceof Error ? err.message : String(err);
1734
+ for (const auth of this.authCache.values()) {
1735
+ if (auth.APIKey && raw.includes(auth.APIKey))
1736
+ raw = raw.split(auth.APIKey).join('***');
1737
+ if (auth.EventID && raw.includes(auth.EventID))
1738
+ raw = raw.split(auth.EventID).join('***');
1739
+ }
1740
+ return raw;
1741
+ }
1742
+ /** Emits a warning at most once per connector lifetime, so the log stays honest rather than noisy. */
1743
+ WarnOnce(key, message) {
1744
+ if (this.warnedOnce.has(key))
1745
+ return;
1746
+ this.warnedOnce.add(key);
1747
+ console.warn(message);
1748
+ }
1749
+ };
1750
+ EventscribeConnector = __decorate([
1751
+ RegisterClass(BaseIntegrationConnector, 'EventscribeConnector')
1752
+ ], EventscribeConnector);
1753
+ export { EventscribeConnector };
1754
+ /** Forces the module (and its `@RegisterClass` side effect) to be retained by a bundler. */
1755
+ export function LoadEventscribeConnector() {
1756
+ // no-op: registration happened on import
1757
+ }
1758
+ //# sourceMappingURL=EventscribeConnector.js.map