@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.
- package/dist/EventscribeConnector.d.ts +538 -0
- package/dist/EventscribeConnector.js +1758 -0
- package/dist/EventscribeConnector.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/package.json +42 -0
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
import { type UserInfo } from '@memberjunction/core';
|
|
2
|
+
import type { MJCompanyIntegrationEntity, MJIntegrationObjectEntity } from '@memberjunction/core-entities';
|
|
3
|
+
import { BaseRESTIntegrationConnector, type RESTAuthContext, type RESTResponse, type PaginationState, type PaginationType, type RateLimitPolicy, type ConnectionTestResult, type ExternalRecord, type FetchContext, type FetchBatchResult, type SourceSchemaInfo, type SyncErrorCode, type ErrorSeverity, type CreateRecordContext, type UpdateRecordContext, type DeleteRecordContext, type GetRecordContext, type CRUDResult } from '@memberjunction/integration-engine';
|
|
4
|
+
/** Resolved auth for one connection. The API key is static — Cadmium documents no token endpoint. */
|
|
5
|
+
export interface EventscribeAuthContext extends RESTAuthContext {
|
|
6
|
+
APIKey: string;
|
|
7
|
+
/** Per-CONNECTION (tenant) event scope. NEVER a value baked into this class. */
|
|
8
|
+
EventID?: string;
|
|
9
|
+
IntegrationID: string;
|
|
10
|
+
}
|
|
11
|
+
/** A classified vendor failure, kept structured so the engine can route it. */
|
|
12
|
+
export interface EventscribeErrorClassification {
|
|
13
|
+
Code: SyncErrorCode;
|
|
14
|
+
Severity: ErrorSeverity;
|
|
15
|
+
Retryable: boolean;
|
|
16
|
+
Reason: string;
|
|
17
|
+
}
|
|
18
|
+
/** Typed transport failure carrying the vendor's own message and the classified verdict. */
|
|
19
|
+
export declare class EventscribeAPIError extends Error {
|
|
20
|
+
readonly Status: number;
|
|
21
|
+
readonly Headers: Record<string, string>;
|
|
22
|
+
readonly Classification: EventscribeErrorClassification;
|
|
23
|
+
readonly VendorMessage?: string | undefined;
|
|
24
|
+
constructor(message: string, Status: number, Headers: Record<string, string>, Classification: EventscribeErrorClassification, VendorMessage?: string | undefined);
|
|
25
|
+
}
|
|
26
|
+
export declare class EventscribeConnector extends BaseRESTIntegrationConnector {
|
|
27
|
+
/**
|
|
28
|
+
* The object + verb the CURRENT async call chain is serving. The base class's `GetBaseURL`,
|
|
29
|
+
* `ExtractPaginationInfo`, `BuildOperationBody` and `ExtractIDFromResponse` hooks are called
|
|
30
|
+
* without an object argument, but every one of them is per-object on this vendor (five families,
|
|
31
|
+
* three hosts, per-object pagination envelope, per-object array-body write convention). An
|
|
32
|
+
* AsyncLocalStorage scope carries the identity correctly even when the engine pushes several
|
|
33
|
+
* objects concurrently — a mutable `this.currentObject` field would silently cross the wires.
|
|
34
|
+
*/
|
|
35
|
+
private readonly scope;
|
|
36
|
+
/** Resolved auth per CompanyIntegration.ID. The APIKey is static; there is nothing to refresh. */
|
|
37
|
+
private readonly authCache;
|
|
38
|
+
/** Earliest permitted send time per `host|MethodValue`, so the vendor's documented spacing is honoured. */
|
|
39
|
+
private readonly nextAllowedAt;
|
|
40
|
+
/** Warnings already emitted, so a long sync logs honestly rather than noisily. */
|
|
41
|
+
private readonly warnedOnce;
|
|
42
|
+
/** Verbatim `MJ: Integrations.Name`. Load-bearing: T1 compares this === the metadata Name. */
|
|
43
|
+
get IntegrationName(): string;
|
|
44
|
+
/**
|
|
45
|
+
* TRUE. `Configuration.WriteCapability` documents `addUpdateAccount` (eventscribe-web),
|
|
46
|
+
* `addUpdateExhibitor` / `addUpdateBooth` / `addUpdateExhibitorStaff` (expo-harvester) and
|
|
47
|
+
* `addUpdatePresenter` / `addUpdatePresentation` (education-harvester); those objects carry
|
|
48
|
+
* populated `CreateAPIPath` + `CreateMethod` columns and ride the base class's generic create.
|
|
49
|
+
*/
|
|
50
|
+
get SupportsCreate(): boolean;
|
|
51
|
+
/** TRUE for the same `addUpdate*` upsert operations — they are create-OR-update in one call. */
|
|
52
|
+
get SupportsUpdate(): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* TRUE, but narrowly: only Account (`cancelAccount` / `deleteAccount`) and Presentation
|
|
55
|
+
* (`deletePresentation`) declare a delete operation. `Configuration.WriteCapability` records
|
|
56
|
+
* expo-harvester's `unassignBooth` as explicitly NOT a delete, and abstract-scorecard as 100%
|
|
57
|
+
* read-only, so those objects leave `DeleteAPIPath` null and the generic delete refuses them.
|
|
58
|
+
*/
|
|
59
|
+
get SupportsDelete(): boolean;
|
|
60
|
+
/**
|
|
61
|
+
* FALSE, permanently. `Configuration.DiscoveryIsAuthoritativeReason` (metadata): Cadmium documents
|
|
62
|
+
* NO list/describe/schema endpoint anywhere in the corpus, so "absence from a sample response
|
|
63
|
+
* proves nothing about what the vendor's schema actually supports". A thin runtime result must
|
|
64
|
+
* never deactivate a persisted object or field — that would be tenant-visible data loss.
|
|
65
|
+
*/
|
|
66
|
+
get DiscoveryIsAuthoritative(): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Read STRAIGHT off `Configuration.RateLimits.standard` ("1 request per 1000 ms, most methods,
|
|
69
|
+
* vendor-wide"). Returns null when the integration row carries no rate-limit facts — the engine
|
|
70
|
+
* then paces itself rather than obeying a number this class invented. The per-METHOD overrides
|
|
71
|
+
* (the two vendor-documented heavy methods at 1/60s) cannot be expressed in this connector-wide
|
|
72
|
+
* policy, so they are enforced per request in {@link PaceRequest}.
|
|
73
|
+
*/
|
|
74
|
+
get RateLimitPolicy(): RateLimitPolicy | null;
|
|
75
|
+
/**
|
|
76
|
+
* One in flight when the vendor's documented standard allowance is one request per window —
|
|
77
|
+
* derived from the same `Configuration.RateLimits.standard` fact, not asserted here. Null when the
|
|
78
|
+
* metadata carries no allowance, so the engine keeps its own default.
|
|
79
|
+
*/
|
|
80
|
+
get MaxConcurrencyHint(): number | null;
|
|
81
|
+
/**
|
|
82
|
+
* Strictly whatever the object's own `StableOrderingKey` column declares. Never synthesised: an
|
|
83
|
+
* invented resume cursor on a source with no server-side ordering guarantee silently skips rows.
|
|
84
|
+
*/
|
|
85
|
+
StableOrderingKey(objectName: string): string | null;
|
|
86
|
+
/**
|
|
87
|
+
* TRUE. The `addUpdate*` operations are REAL batch endpoints: `Configuration.BatchSemantics`
|
|
88
|
+
* documents "JSON array in the raw POST body (single-object writes still require wrapping in a
|
|
89
|
+
* one-element array)" with per-record, NON-ATOMIC processing. See {@link BatchCreateRecords}.
|
|
90
|
+
*/
|
|
91
|
+
get SupportsBatchWrite(): boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Declared ∪ live-sampled, so a tenant's own columns reach the schema builder. Cadmium's field
|
|
94
|
+
* sets are demonstrably per-tenant (`AccountCustomField1..10`, `AuthorCustomFieldN`,
|
|
95
|
+
* `SubmitterCustomFieldN`, `ReviewAnswerN` are all templated in the vendor docs), and with no
|
|
96
|
+
* describe endpoint the ONLY way to learn which of them a given event actually populates is to
|
|
97
|
+
* read real records. The union is delegated to the shared never-shrink helper; this connector
|
|
98
|
+
* supplies no merge logic of its own, and a sampling failure leaves the DECLARED floor intact.
|
|
99
|
+
*/
|
|
100
|
+
IntrospectSchema(companyIntegration: MJCompanyIntegrationEntity, contextUser: UserInfo): Promise<SourceSchemaInfo>;
|
|
101
|
+
/**
|
|
102
|
+
* Runs the cheapest real read this connection can make: the first ACTIVE, directly-queryable
|
|
103
|
+
* object's own door. Objects whose only door needs a caller-supplied record key
|
|
104
|
+
* (`Configuration.requiresRecordKeyToRead`) are skipped — calling them unkeyed proves nothing.
|
|
105
|
+
* The message never carries credential bytes.
|
|
106
|
+
*/
|
|
107
|
+
TestConnection(companyIntegration: MJCompanyIntegrationEntity, contextUser: UserInfo): Promise<ConnectionTestResult>;
|
|
108
|
+
/**
|
|
109
|
+
* OVERRIDDEN for two evidenced reasons, and it delegates back to the base for everything else.
|
|
110
|
+
*
|
|
111
|
+
* (a) MULTI-HOST. The object's identity has to be in scope before `GetBaseURL` runs, because the
|
|
112
|
+
* base's signature has no object slot and this vendor has three hosts.
|
|
113
|
+
* (b) NESTED ACCESS PATHS. A large minority of the declared objects carry `accessPath.depth >= 1`
|
|
114
|
+
* with the note "This object has no read operation of its own; its records arrive nested
|
|
115
|
+
* inside the door operation's response under the '<key>' key." A flat per-object query
|
|
116
|
+
* returns ZERO rows for every one of them, so those walk the declared path instead.
|
|
117
|
+
*
|
|
118
|
+
* A depth-0 object goes straight back to `super.FetchChanges` — the base's pagination loop,
|
|
119
|
+
* batch limiting and record assembly are used AS IS. Two metadata-declared refusals run FIRST
|
|
120
|
+
* ({@link WireFormatGate}, {@link RecordKeyGate}) so an object this connector cannot honestly read
|
|
121
|
+
* reports a structured warning instead of a silent, green, zero-row batch.
|
|
122
|
+
*/
|
|
123
|
+
FetchChanges(ctx: FetchContext): Promise<FetchBatchResult>;
|
|
124
|
+
/**
|
|
125
|
+
* Refuses an object whose metadata declares a wire format this connector does not parse.
|
|
126
|
+
* `Configuration.responseFormat` is a per-object FACT: the five in-scope families are all `json`,
|
|
127
|
+
* while the EdgeReg family ships `xml` (and its own `responseFormatNote`: "The connector must parse
|
|
128
|
+
* XML for this object") together with a DIFFERENT credential model. Those objects are seeded
|
|
129
|
+
* `Status = 'Disabled'` so they normally never reach a sync at all; this gate is what happens if an
|
|
130
|
+
* operator activates one anyway. Without it the XML body fails to parse as JSON, `NormalizeResponse`
|
|
131
|
+
* yields `[]`, and the run reports zero rows and GREEN — the silent-empty this framework exists to
|
|
132
|
+
* prevent. The refusal names the declared format and the credential model so the fix is obvious.
|
|
133
|
+
*/
|
|
134
|
+
private WireFormatGate;
|
|
135
|
+
/**
|
|
136
|
+
* The DECLARED incremental window for one read, or undefined for a full pull. Everything is
|
|
137
|
+
* metadata: the parameter name comes from the object's own `Configuration.watermark.startParam`,
|
|
138
|
+
* and the VALUE is the watermark the engine handed back — which this connector originally took
|
|
139
|
+
* from the record's OWN `IncrementalWatermarkField` (see {@link WithMaxSeenWatermark}), so it is
|
|
140
|
+
* already in the vendor's own serialization and no format is invented on the wire.
|
|
141
|
+
*
|
|
142
|
+
* `endParam` is deliberately NOT sent even where declared: an upper bound would silently drop any
|
|
143
|
+
* record the vendor writes between the request being built and being served. No `startParam` in
|
|
144
|
+
* metadata ⇒ no window — this vendor's per-object `watermarkProvenNegative` records that the five
|
|
145
|
+
* in-scope families document no server-side modified-since filter at all, and an invented one
|
|
146
|
+
* either returns nothing or is ignored.
|
|
147
|
+
*/
|
|
148
|
+
private WindowParamsFor;
|
|
149
|
+
/**
|
|
150
|
+
* Advances the watermark to the MAX value SEEN in this batch, and only for an object whose metadata
|
|
151
|
+
* declares one (`SupportsIncrementalSync` + `IncrementalWatermarkField`). Never advances past a
|
|
152
|
+
* value already recorded, and never invents a watermark for a full-pull object — the five in-scope
|
|
153
|
+
* families are all `FullPullHashDiff`, where the engine's content-hash idempotency does the work.
|
|
154
|
+
*/
|
|
155
|
+
private WithMaxSeenWatermark;
|
|
156
|
+
/** Chronological when BOTH values parse as dates, lexicographic otherwise. Never coerces one side. */
|
|
157
|
+
private CompareWatermark;
|
|
158
|
+
/**
|
|
159
|
+
* Walks a declared nesting path: fire the DOOR operation, then descend into the declared container
|
|
160
|
+
* key on each door record and emit the leaf rows. Everything that varies — the door's Method value,
|
|
161
|
+
* the container key, the parent key field to tag the leaf with — comes from the object's own
|
|
162
|
+
* `Configuration.accessPath` / `nestedContainerKey` / `parentObjectIDFieldName`; nothing is guessed.
|
|
163
|
+
*
|
|
164
|
+
* Returns ONE batch with `HasMore: false`. Every depth>=1 object declares
|
|
165
|
+
* `SupportsPagination = false`, so the door is a single unpaged call: splitting the leaves across
|
|
166
|
+
* batches would force a full re-read of the door per batch for no benefit.
|
|
167
|
+
*/
|
|
168
|
+
private FetchNestedViaDoor;
|
|
169
|
+
/**
|
|
170
|
+
* The honest refusal for an object whose ONLY read door needs a caller-supplied record key.
|
|
171
|
+
* `Configuration.requiresRecordKeyToRead` states it verbatim: "A full sync cannot enumerate this
|
|
172
|
+
* object unaided". The vendor documents no parameter name for that key on the read side, so this
|
|
173
|
+
* returns an empty batch with a loud, structured warning rather than firing a call that is
|
|
174
|
+
* guaranteed to fail — or, worse, inventing a query parameter.
|
|
175
|
+
*/
|
|
176
|
+
private RecordKeyGate;
|
|
177
|
+
/**
|
|
178
|
+
* Resolves the per-connection credential. Cadmium's APIKey is a single static per-client string
|
|
179
|
+
* with no authorize/token endpoint, no scopes and no documented expiry, so there is nothing to
|
|
180
|
+
* refresh and the resolved context is cached per CompanyIntegration. `eID` is the per-CONNECTION
|
|
181
|
+
* (TENANT) event scope — `Configuration.AuthMultiTenantParam` is explicit that it is "never baked
|
|
182
|
+
* into connector code" — and is optional, used only when the key is provisioned for multi-event
|
|
183
|
+
* access. The credential is read through the standard `MJ: Credentials` record when the connection
|
|
184
|
+
* carries one, with the connection's own `Configuration` JSON as the fallback. No inline crypto.
|
|
185
|
+
*/
|
|
186
|
+
protected Authenticate(companyIntegration: MJCompanyIntegrationEntity, contextUser: UserInfo): Promise<EventscribeAuthContext>;
|
|
187
|
+
/**
|
|
188
|
+
* Transport headers ONLY — deliberately NOTHING auth-related. `Configuration.AuthHeaderPattern` is
|
|
189
|
+
* null and `AuthCredentialParamLocation` is 'query': the API key travels in the query string, and a
|
|
190
|
+
* Bearer/Basic header on this vendor is simply wrong. The credential is injected in
|
|
191
|
+
* {@link SendRequest}, which is the one place it ever touches the wire.
|
|
192
|
+
*/
|
|
193
|
+
protected BuildHeaders(_auth: RESTAuthContext): Record<string, string>;
|
|
194
|
+
/**
|
|
195
|
+
* The wire choke point used by every read and every single-record write. On top of
|
|
196
|
+
* {@link SendRequest} (credential injection, vendor pacing, the documented 404+`[]` empty case) it
|
|
197
|
+
* adds ONE rule: a 2xx response whose body carries the vendor's `{"error": ...}` envelope is a
|
|
198
|
+
* FAILURE, not an empty read. `Configuration.ErrorContract` documents that envelope as applying
|
|
199
|
+
* "across API methods"; a body-blind success check would sync zero rows and report green.
|
|
200
|
+
*
|
|
201
|
+
* On a READ it adds the SAME status gate the connector's other two read call sites already apply
|
|
202
|
+
* ({@link FetchNestedViaDoor}, {@link GetRecord}): a non-2xx never reaches record assembly, and it
|
|
203
|
+
* surfaces as the connector's own classified {@link EventscribeAPIError} rather than an unclassified
|
|
204
|
+
* failure — a 5xx must reach the engine as `Retryable: true`, a 401/403 as a non-retryable
|
|
205
|
+
* configuration error. The flat/paginated read the base class drives was the one transport path
|
|
206
|
+
* with no such gate, so its errors carried no `Code`/`Severity`/`Retryable` verdict at all.
|
|
207
|
+
*
|
|
208
|
+
* OUTSIDE a read, NON-2xx responses are returned, not thrown, so the base class's generic CRUD can
|
|
209
|
+
* build a proper `CRUDResult`, `GetRecord` keeps its documented 404 ⇒ null, and `TestConnection`
|
|
210
|
+
* can report the status it observed. The batch path calls {@link SendRequest} directly because for
|
|
211
|
+
* `addUpdateAccount` an HTTP 400 can accompany partially-succeeded records and must be INSPECTED
|
|
212
|
+
* rather than treated as total failure.
|
|
213
|
+
*/
|
|
214
|
+
protected MakeHTTPRequest(auth: RESTAuthContext, url: string, method: string, headers: Record<string, string>, body?: unknown): Promise<RESTResponse>;
|
|
215
|
+
/**
|
|
216
|
+
* Credential injection + vendor pacing + the documented empty-result special case. Returns the
|
|
217
|
+
* response verbatim for every status; classification is the caller's decision.
|
|
218
|
+
*
|
|
219
|
+
* `Configuration.ErrorContract.specialCases` is explicit for the asset family: HTTP 404 with a body
|
|
220
|
+
* of `[]` means "no presentations or posters were found" — an EMPTY RESULT, never a connector
|
|
221
|
+
* failure. It is normalised to a 200 here so both the base read path (which validates on status)
|
|
222
|
+
* and this connector's own paths see an empty success.
|
|
223
|
+
*/
|
|
224
|
+
protected SendRequest(auth: RESTAuthContext, url: string, method: string, headers: Record<string, string>, body?: unknown): Promise<RESTResponse>;
|
|
225
|
+
/** Raw transport. Isolated so tests can substitute it without touching any connector behaviour. */
|
|
226
|
+
protected rawRequest(url: string, method: string, headers: Record<string, string>, body?: unknown): Promise<RESTResponse>;
|
|
227
|
+
/**
|
|
228
|
+
* Strips the vendor envelope. A BARE JSON ARRAY is the common shape across this vendor (Asset,
|
|
229
|
+
* Expo, Education Harvester); `ResponseDataKey` is applied ONLY where the object's metadata
|
|
230
|
+
* declares one (the abstract-scorecard family's `{ metadata: {...}, results: [...] }`). A single
|
|
231
|
+
* record object is a one-element result — several doors (`getAccount`, `getSingle*`) answer with
|
|
232
|
+
* one object, not an array — but an error envelope is never mistaken for a record.
|
|
233
|
+
*/
|
|
234
|
+
protected NormalizeResponse(rawBody: unknown, responseDataKey: string | null): Record<string, unknown>[];
|
|
235
|
+
/**
|
|
236
|
+
* PageNumber only, and only from the object's OWN declared envelope
|
|
237
|
+
* (`Configuration.pagination.envelope` = `{ container: 'metadata', totalRecordsKey: 'totalRecords',
|
|
238
|
+
* totalPagesKey: 'pages', currentPageKey: 'page' }`). An object whose metadata declares
|
|
239
|
+
* `SupportsPagination = false` never reaches here — the base short-circuits it — and when the
|
|
240
|
+
* envelope is not declared this returns `HasMore: false` rather than inventing a counter name.
|
|
241
|
+
* Inventing one either truncates the sync or loops it forever.
|
|
242
|
+
*/
|
|
243
|
+
protected ExtractPaginationInfo(rawBody: unknown, paginationType: PaginationType, currentPage: number, _currentOffset: number, pageSize: number, obj?: MJIntegrationObjectEntity): PaginationState;
|
|
244
|
+
/**
|
|
245
|
+
* Emits ONLY the page parameter the object's metadata proves
|
|
246
|
+
* (`Configuration.pagination.paramName`). The base class's default would append `page=` AND
|
|
247
|
+
* `pageSize=`; Cadmium's vendor table documents the page-number parameter and NO page-size
|
|
248
|
+
* parameter at all, so sending one would be an invented name on the wire.
|
|
249
|
+
*/
|
|
250
|
+
protected BuildPaginatedURL(basePath: string, obj: MJIntegrationObjectEntity, page: number, _offset: number, _cursor?: string, _effectivePageSize?: number): string;
|
|
251
|
+
/**
|
|
252
|
+
* PER-OBJECT, multi-host. Resolution order, all of it metadata:
|
|
253
|
+
* 1. the object's own `Configuration.baseUrl`, when a build ever declares one;
|
|
254
|
+
* 2. `Integration.Configuration.BaseURLsByFamily` keyed by the object's `Configuration.family`
|
|
255
|
+
* (falling back to `IntegrationObject.Category`, which carries the same family tag);
|
|
256
|
+
* 3. the object's `Configuration.absoluteEndpoint` with its own declared `APIPath` suffix
|
|
257
|
+
* removed — the last resort that still rescues a family with no table entry.
|
|
258
|
+
*
|
|
259
|
+
* 'asset' and 'eventscribe-web' both key this table and both must stay: they resolve to the same
|
|
260
|
+
* host TODAY, which is data, not a licence to collapse the tags in code. There is no default and
|
|
261
|
+
* no baked host — a family with no resolvable base URL raises, it does not silently pick one.
|
|
262
|
+
*/
|
|
263
|
+
protected GetBaseURL(companyIntegration: MJCompanyIntegrationEntity, _auth: RESTAuthContext, objectName?: string): string;
|
|
264
|
+
/** `BaseURL` off a connection's Configuration JSON, when present and non-empty. */
|
|
265
|
+
private SingleOriginOverride;
|
|
266
|
+
/** The per-object base URL resolution described on {@link GetBaseURL}. */
|
|
267
|
+
private BaseURLForObject;
|
|
268
|
+
/** Scope-only wrapper; the create itself is the base class's metadata-driven generic dispatch. */
|
|
269
|
+
CreateRecord(ctx: CreateRecordContext): Promise<CRUDResult>;
|
|
270
|
+
/** Scope-only wrapper; the update itself is the base class's metadata-driven generic dispatch. */
|
|
271
|
+
UpdateRecord(ctx: UpdateRecordContext): Promise<CRUDResult>;
|
|
272
|
+
/**
|
|
273
|
+
* Scope-only wrapper around the base class's metadata-driven generic dispatch, PLUS one refusal:
|
|
274
|
+
* a delete whose declared request carries NO record identifier never goes on the wire. See
|
|
275
|
+
* {@link UnidentifiedDeleteGuard} — this is a safety gate, not a re-implementation.
|
|
276
|
+
*/
|
|
277
|
+
DeleteRecord(ctx: DeleteRecordContext): Promise<CRUDResult>;
|
|
278
|
+
/**
|
|
279
|
+
* Refuses a DESTRUCTIVE request that cannot name the record it is destroying.
|
|
280
|
+
*
|
|
281
|
+
* The base's generic delete substitutes the external id into the path ONLY when
|
|
282
|
+
* `DeleteIDLocation = 'path'` and the path carries an `{ID}` placeholder, and it sends NO body at
|
|
283
|
+
* all. Some of this vendor's delete-adjacent operations declare `DeleteIDLocation = 'n/a'` with
|
|
284
|
+
* `deleteOperation.idParam = null` because — per the frozen contract's own gap list — "the op
|
|
285
|
+
* documents no ID parameter at all in its parameters array". Firing that path verbatim would put
|
|
286
|
+
* an UNIDENTIFIED delete on a live event with nothing but the API key, the event scope and the
|
|
287
|
+
* Method name. Best case it 400s; worst case the vendor interprets it broadly. Neither is a risk
|
|
288
|
+
* worth taking to make a capability flag look satisfied.
|
|
289
|
+
*
|
|
290
|
+
* So this returns a FAILED CRUDResult naming the exact missing fact, rather than either (a) firing
|
|
291
|
+
* blind or (b) inventing a query-parameter name the vendor never documented — which would be the
|
|
292
|
+
* connector silently working around a metadata gap. Objects whose delete DOES carry an identifier
|
|
293
|
+
* (`...&AccountID={ID}` with `DeleteIDLocation = 'path'`) are untouched and ride the generic path.
|
|
294
|
+
*/
|
|
295
|
+
private UnidentifiedDeleteGuard;
|
|
296
|
+
/**
|
|
297
|
+
* THE SECOND GENUINELY IDIOSYNCRATIC PATH. This is the one read the base class cannot express on an
|
|
298
|
+
* RPC-over-querystring API: its generic `GetRecord` reuses `UpdateAPIPath` as the get-one path
|
|
299
|
+
* ("typically the same as the get-one path" — true of resource-oriented REST, where `/accounts/{id}`
|
|
300
|
+
* is both). Here `UpdateAPIPath` is `...?Method=addUpdateAccount&AccountID={ID}`, so the generic path
|
|
301
|
+
* would send a GET whose `Method` names a WRITE operation. `Configuration.ReadContract` is explicit
|
|
302
|
+
* that "the Method query param IS the routing/dispatch mechanism" — the verb does not disambiguate
|
|
303
|
+
* it — so that request is an upsert dispatched with no body, aimed at a live event. Refusing to build
|
|
304
|
+
* it is the same judgement as {@link UnidentifiedDeleteGuard}.
|
|
305
|
+
*
|
|
306
|
+
* Instead the read goes through the object's DECLARED READ DOOR ({@link DoorOperationFor}) with the
|
|
307
|
+
* record key on the query string under its DECLARED parameter name ({@link RecordKeyParamFor}).
|
|
308
|
+
* Everything is metadata; nothing is inferred from the verb or invented from a naming convention.
|
|
309
|
+
* A throw here is safe and preferred over a wrong request: the engine's only caller treats a failed
|
|
310
|
+
* re-read as "proceed with the full attribute set", so a refusal degrades to prior behaviour.
|
|
311
|
+
*/
|
|
312
|
+
GetRecord(ctx: GetRecordContext): Promise<ExternalRecord | null>;
|
|
313
|
+
/**
|
|
314
|
+
* The query-parameter name that names ONE record on a read, in metadata order: the operation's own
|
|
315
|
+
* declared `idParam` first, then the object's single declared primary key. A COMPOSITE key returns
|
|
316
|
+
* null — this vendor documents no multi-key single-record door, and splitting one across invented
|
|
317
|
+
* parameter names would be a fabrication. So does an object with no declared key at all: the frozen
|
|
318
|
+
* contract withdrew seven weakly-evidenced keys, and its no-identity path forbids substituting a guess.
|
|
319
|
+
*/
|
|
320
|
+
private RecordKeyParamFor;
|
|
321
|
+
/**
|
|
322
|
+
* Wraps the generic flat body in a ONE-ELEMENT ARRAY for the operations whose metadata declares the
|
|
323
|
+
* array-body convention. `Configuration.BatchSemantics` is explicit: "JSON array in the raw POST
|
|
324
|
+
* body (single-object writes still require wrapping in a one-element array)". Sending the bare
|
|
325
|
+
* object instead is a malformed request for those operations. Everything else keeps the base
|
|
326
|
+
* class's shape untouched — the decision is per-operation and read from metadata (see
|
|
327
|
+
* {@link UsesArrayBody}), never a list of vendor operation names written into this file.
|
|
328
|
+
*/
|
|
329
|
+
protected BuildOperationBody(attributes: Record<string, unknown>, bodyShape: string | null, bodyKey: string | null): unknown;
|
|
330
|
+
/**
|
|
331
|
+
* Reads the new record's id from the vendor's response. The base helper only knows the generic
|
|
332
|
+
* `id`/`ID` names; Cadmium returns the record's OWN key (Booth's `createIDBasis`: "the operation's
|
|
333
|
+
* own sample response contains the record key 'BoothID'"), and for an array-body write the response
|
|
334
|
+
* is an ARRAY of per-record results. Candidate names come from the object's declared
|
|
335
|
+
* `writeOperation.idParam` and then its declared primary-key columns — from metadata, in order.
|
|
336
|
+
* `IDLocation = 'n/a'` means the vendor documents NO id in the response; that returns undefined
|
|
337
|
+
* rather than reaching for a field that was never promised.
|
|
338
|
+
*/
|
|
339
|
+
protected ExtractIDFromResponse(response: RESTResponse, idLocation: string | null): string | undefined;
|
|
340
|
+
/** Reads the vendor's own message out of the documented `{"error": ...}` envelope. */
|
|
341
|
+
protected ExtractErrorMessage(response: RESTResponse): string | undefined;
|
|
342
|
+
/**
|
|
343
|
+
* THE ONE GENUINELY IDIOSYNCRATIC WRITE PATH. `Configuration.BatchSemantics.addUpdateAccount`:
|
|
344
|
+
* "non-atomic -- each record in the array is processed INDEPENDENTLY ... If ANY record in the batch
|
|
345
|
+
* fails, the overall HTTP response status is 400 -- but valid records in the SAME request are still
|
|
346
|
+
* created/updated. Callers must inspect the individual per-record results in the response body".
|
|
347
|
+
*
|
|
348
|
+
* So this sends ONE request carrying the whole array and then reads the PER-RECORD results out of
|
|
349
|
+
* the body — a 400 is not treated as total failure. When the per-record results cannot be located
|
|
350
|
+
* positionally, it degrades CONSERVATIVELY: a 2xx reports success, a non-2xx reports failure for
|
|
351
|
+
* every record with the vendor's message, and no record is ever claimed successful on a guess
|
|
352
|
+
* (the `addUpdate*` operations are upserts, so a conservative re-push is idempotent).
|
|
353
|
+
*/
|
|
354
|
+
BatchCreateRecords(ctxs: CreateRecordContext[]): Promise<CRUDResult[]>;
|
|
355
|
+
/**
|
|
356
|
+
* Same array-body batch, for the `addUpdate*` upserts on the update side — but ONLY when the
|
|
357
|
+
* object's declared `UpdateAPIPath` carries no `{ID}` placeholder. Every current object declares
|
|
358
|
+
* one (`...&AccountID={ID}`), which is the SINGLE-record URL shape, and stripping it to force a
|
|
359
|
+
* batch would be inventing a request. Those fall back to the per-record path: correctness first,
|
|
360
|
+
* throughput second.
|
|
361
|
+
*/
|
|
362
|
+
BatchUpdateRecords(ctxs: UpdateRecordContext[]): Promise<CRUDResult[]>;
|
|
363
|
+
/** Groups by object, batches the eligible groups, and routes the rest through the single-record path. */
|
|
364
|
+
private RunArrayBodyBatch;
|
|
365
|
+
/**
|
|
366
|
+
* Reads the per-record results out of a non-atomic array-body response. The vendor documents that
|
|
367
|
+
* they exist and must be inspected, but never prints their exact schema, so this locates them
|
|
368
|
+
* POSITIONALLY: the response array (or the first array-valued property of the response object)
|
|
369
|
+
* whose length matches the request array. Each item fails when it carries the documented error
|
|
370
|
+
* envelope; otherwise it succeeded — even under an overall HTTP 400, which is precisely the
|
|
371
|
+
* partial-success case. No positional array ⇒ the conservative all-or-nothing verdict.
|
|
372
|
+
*/
|
|
373
|
+
private ReadPerRecordOutcomes;
|
|
374
|
+
/** The response array whose length matches the request array, at the root or one level down. */
|
|
375
|
+
private LocatePositionalResults;
|
|
376
|
+
/**
|
|
377
|
+
* Whether the object's write/delete operation takes a JSON ARRAY body, decided ENTIRELY from
|
|
378
|
+
* metadata: a structural `requestShape` on the operation, a structural
|
|
379
|
+
* `Integration.Configuration.BatchSemantics[<operationId>]` entry, or the vendor's own
|
|
380
|
+
* cross-operation convention statement — which is matched against the operation id the OBJECT's
|
|
381
|
+
* metadata supplies, so no vendor operation name is ever written into this file.
|
|
382
|
+
*/
|
|
383
|
+
private UsesArrayBody;
|
|
384
|
+
/** Candidate id field names for the CURRENT scope's object, in metadata order. Never a guessed name. */
|
|
385
|
+
private WriteIDFieldNames;
|
|
386
|
+
/**
|
|
387
|
+
* Honours the vendor's documented spacing before every request. The window comes from metadata —
|
|
388
|
+
* `Integration.Configuration.RateLimits.standard` for the vendor-wide allowance, its `overrides`
|
|
389
|
+
* (and any object whose own `Configuration.rateLimit` is scoped `object-override`) for the two
|
|
390
|
+
* documented heavy methods that require 60 s between calls. Keyed by `host|MethodValue`, because
|
|
391
|
+
* on this RPC API a "method" is the operation, not the URL path. No window in metadata ⇒ no pacing
|
|
392
|
+
* invented here; the engine's own adaptive limiter still applies.
|
|
393
|
+
*
|
|
394
|
+
* SCOPED TO THE HOSTS THE METADATA DECLARES ({@link VendorHosts}). The documented allowance is a
|
|
395
|
+
* property of Cadmium's OWN service, identified in metadata by host. When a connection is pointed
|
|
396
|
+
* somewhere else — an operator's gateway, a staging or replay endpoint — this connector holds NO
|
|
397
|
+
* documented allowance for that host, and imposing a 60-second sleep on a service whose real policy
|
|
398
|
+
* is unknown is an invented number, not a safe default. Those requests are governed by the engine's
|
|
399
|
+
* adaptive limiter and its 429 handling instead. Declares no host at all ⇒ everything is paced.
|
|
400
|
+
*/
|
|
401
|
+
private PaceRequest;
|
|
402
|
+
/** The documented spacing for one operation: per-method override first, vendor-wide standard second. */
|
|
403
|
+
private RateWindowFor;
|
|
404
|
+
/** A per-object `Configuration.rateLimit` explicitly scoped `object-override`, matched by Method value. */
|
|
405
|
+
private ObjectRateOverrideFor;
|
|
406
|
+
/**
|
|
407
|
+
* The hosts the METADATA declares for this vendor: every `BaseURLsByFamily` entry plus every
|
|
408
|
+
* object's own `Configuration.absoluteEndpoint` (which is what carries the out-of-scope families
|
|
409
|
+
* whose hosts the family table deliberately omits). This is the set the vendor's documented
|
|
410
|
+
* rate-limit applies to — see {@link PaceRequest}. Never a literal: an empty set means the
|
|
411
|
+
* metadata named no host, and the conservative "pace everything" branch takes over.
|
|
412
|
+
*/
|
|
413
|
+
private VendorHosts;
|
|
414
|
+
/** Sleeps. Isolated so tests can assert the pacing decision without waiting for it. */
|
|
415
|
+
protected Sleep(ms: number): Promise<void>;
|
|
416
|
+
/**
|
|
417
|
+
* Adds the credential query parameters. Their NAMES come from
|
|
418
|
+
* `Integration.Configuration.AuthCredentialParamName` ("APIKey") and
|
|
419
|
+
* `Configuration.AuthMultiTenantParam.name` ("eID"); if the metadata does not name the credential
|
|
420
|
+
* parameter, this raises rather than guessing a name onto the wire. `eID` is added only when the
|
|
421
|
+
* connection actually carries an event id, and an existing value in the URL is never overwritten.
|
|
422
|
+
*/
|
|
423
|
+
private WithCredentialParams;
|
|
424
|
+
/**
|
|
425
|
+
* Appends the DECLARED incremental-window parameters resolved by {@link WindowParamsFor} for the
|
|
426
|
+
* read currently in scope. Absent scope, or an object with no declared window, is a no-op — so a
|
|
427
|
+
* full-pull object's URL is byte-identical to what it was before. An existing value on the URL is
|
|
428
|
+
* never overwritten.
|
|
429
|
+
*/
|
|
430
|
+
private WithWindowParams;
|
|
431
|
+
/**
|
|
432
|
+
* `<familyBase><APIPath>?<MethodParam>=<operation>` for one object's door.
|
|
433
|
+
*
|
|
434
|
+
* The base URL is resolved through {@link GetBaseURL} — NOT by calling {@link BaseURLForObject}
|
|
435
|
+
* directly — so that EVERY request this connector makes goes through the ONE resolution seam.
|
|
436
|
+
* `GetBaseURL` is the documented per-connection override point (an operator pointing a connection
|
|
437
|
+
* at a gateway, a harness redirecting the origin); a path that reaches around it would obey the
|
|
438
|
+
* declared vendor host while the rest of the connector obeyed the override, which is exactly the
|
|
439
|
+
* kind of split-brain routing that sends half a sync to the wrong origin.
|
|
440
|
+
*/
|
|
441
|
+
private DoorURL;
|
|
442
|
+
/** The dispatch parameter name: the object's own declaration first, the integration contract second. */
|
|
443
|
+
private MethodParamName;
|
|
444
|
+
/** The Method value carried on a built URL, used only to key the vendor's per-method pacing. */
|
|
445
|
+
private MethodValueFromURL;
|
|
446
|
+
/**
|
|
447
|
+
* The operation that RETURNS this object's records. For a nested object that is the DOOR
|
|
448
|
+
* (`accessPath.doorOperation`); for a directly-queryable object it is its own read method. The
|
|
449
|
+
* declared access path wins over `dispatch.methodValue`, because the access path is the statement
|
|
450
|
+
* about where the records actually come from.
|
|
451
|
+
*/
|
|
452
|
+
private DoorOperationFor;
|
|
453
|
+
/** How deep this object sits under its door. 0 = directly queryable. */
|
|
454
|
+
private DepthOf;
|
|
455
|
+
/**
|
|
456
|
+
* The response key carrying a nested object's rows: the explicit `nestedContainerKey`, else the
|
|
457
|
+
* last segment of the declared `accessPath.nestingFieldPath` (`"Exhibitor → Booths[]"` → `Booths`).
|
|
458
|
+
* Both are metadata; nothing is inferred from the payload.
|
|
459
|
+
*/
|
|
460
|
+
private NestedContainerKeyFor;
|
|
461
|
+
/** Parsed `Configuration` JSON for one IntegrationObject; malformed degrades to absent. */
|
|
462
|
+
private ObjectConfig;
|
|
463
|
+
/** Parsed `Integration.Configuration` — the connector-wide vendor facts. */
|
|
464
|
+
private IntegrationConfig;
|
|
465
|
+
/** The raw `Integration.Configuration` string. Isolated so tests can supply it without the engine. */
|
|
466
|
+
protected IntegrationConfigurationJSON(): string | null;
|
|
467
|
+
/** The object the current async call chain is serving, when one is in scope. */
|
|
468
|
+
private ScopedObject;
|
|
469
|
+
/** The cached IntegrationObject by name, or null. Routes through the same seam every read path uses. */
|
|
470
|
+
private TryGetCachedObject;
|
|
471
|
+
/** ACTIVE objects for this integration; absent metadata degrades to an empty list, never a throw. */
|
|
472
|
+
protected getCachedObjects(integrationID: string): MJIntegrationObjectEntity[];
|
|
473
|
+
/** This integration's ID by its verbatim name, or null when metadata is not loaded. */
|
|
474
|
+
protected tryGetIntegrationID(): string | null;
|
|
475
|
+
/** The per-call scope a generic CRUD verb runs inside. */
|
|
476
|
+
private ScopeFor;
|
|
477
|
+
/**
|
|
478
|
+
* Builds an `ExternalRecord` whose `Fields` is the COMPLETE source row — never a projection — so
|
|
479
|
+
* the framework's custom-column capture can still see a per-tenant column this build never
|
|
480
|
+
* declared. Composite keys join with `|`. When a declared key value is missing (several of this
|
|
481
|
+
* vendor's nested leaves have only a WEAK, shape-derived key) the identity falls back to a content
|
|
482
|
+
* hash: a soft key must never be able to REJECT a valid row.
|
|
483
|
+
*/
|
|
484
|
+
private ToEventscribeRecord;
|
|
485
|
+
/**
|
|
486
|
+
* The DECLARED primary-key names in Sequence order — and NOTHING else. Deliberately does NOT use
|
|
487
|
+
* the base class's synthetic `['ID']` fallback: the frozen contract WITHDREW seven weakly-evidenced
|
|
488
|
+
* keys (they survive as ordinary nullable columns, and the `StableOrderingKey` columns that pointed
|
|
489
|
+
* at them were nulled to match), and its no-identity path is explicit that a PK-less object must
|
|
490
|
+
* make NO idempotent-identity claim and must NOT substitute a guessed alternative. Returning `['ID']`
|
|
491
|
+
* here would be exactly that guess — and would silently start claiming identity the day a tenant's
|
|
492
|
+
* payload happens to carry a column literally named `ID`. An empty list routes the object to the
|
|
493
|
+
* content-hash identity in {@link ToEventscribeRecord}, i.e. the append/full-refresh path the
|
|
494
|
+
* contract prescribes, where the engine's own hash idempotency does the deduplication.
|
|
495
|
+
*/
|
|
496
|
+
private PrimaryKeyNames;
|
|
497
|
+
/** Credential record first, connection Configuration second. No inline crypto; nothing is logged. */
|
|
498
|
+
private LoadCredentials;
|
|
499
|
+
/** Extracts the two credential values from a credential / Configuration JSON string. */
|
|
500
|
+
private ParseCredentialJSON;
|
|
501
|
+
/**
|
|
502
|
+
* Classifies from the vendor's own envelope AND the status. `Configuration.ErrorContract` documents
|
|
503
|
+
* `{"error": "<human-readable message>"}` with 400 and 404 observed, and states it applies "across
|
|
504
|
+
* API methods" — so a 2xx carrying that envelope is a FAILURE, which is exactly how a sync would
|
|
505
|
+
* otherwise report zero rows and green at the same time. The one documented exception (404 with a
|
|
506
|
+
* body of `[]`) is normalised to an empty success in {@link SendRequest} and never reaches here.
|
|
507
|
+
*/
|
|
508
|
+
private ErrorFor;
|
|
509
|
+
/** The status/envelope → structured verdict mapping, exposed so tests can assert it directly. */
|
|
510
|
+
ClassifyEventscribeResponse(status: number, vendorMessage: string | undefined): EventscribeErrorClassification;
|
|
511
|
+
/** The vendor's message from the documented envelope, or undefined when the body carries no error. */
|
|
512
|
+
private VendorMessage;
|
|
513
|
+
/** Walks a dotted path into a parsed body. Returns undefined at the first missing segment. */
|
|
514
|
+
private ReadPath;
|
|
515
|
+
/** Joins a base URL with an API path exactly as declared — no path is invented or normalised away. */
|
|
516
|
+
private JoinURL;
|
|
517
|
+
private TrimTrailingSlash;
|
|
518
|
+
/** Lowercased query-parameter keys already present on a URL. */
|
|
519
|
+
private QueryKeys;
|
|
520
|
+
/** Host of a URL, for pacing keys. Falls back to the raw string when it is not parseable. */
|
|
521
|
+
private HostOf;
|
|
522
|
+
/** Path-only view of a URL, for messages that must never carry a query string or a credential. */
|
|
523
|
+
private PathOf;
|
|
524
|
+
/** A narrowing cast to a plain object, or null. */
|
|
525
|
+
private AsObject;
|
|
526
|
+
/** First non-empty string value among `keys` on a parsed object. */
|
|
527
|
+
private FirstString;
|
|
528
|
+
/** A finite number from an arbitrary JSON value, or null. Vendor counters arrive as either type. */
|
|
529
|
+
private FiniteNumber;
|
|
530
|
+
/** Tolerant JSON-object parse; malformed configuration degrades to "absent" rather than crashing. */
|
|
531
|
+
private ParseJSONObject;
|
|
532
|
+
/** An error message safe to log: never carries credential bytes. */
|
|
533
|
+
private SafeMessage;
|
|
534
|
+
/** Emits a warning at most once per connector lifetime, so the log stays honest rather than noisy. */
|
|
535
|
+
private WarnOnce;
|
|
536
|
+
}
|
|
537
|
+
/** Forces the module (and its `@RegisterClass` side effect) to be retained by a bundler. */
|
|
538
|
+
export declare function LoadEventscribeConnector(): void;
|