@memberjunction/connector-reply 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,355 @@
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 ConnectionTestResult, type FetchContext, type FetchBatchResult, type RateLimitPolicy, type SyncErrorCode, type CreateRecordContext, type UpdateRecordContext, type DeleteRecordContext, type CRUDResult, type SourceSchemaInfo } from '@memberjunction/integration-engine';
4
+ /** RFC 9457 problem-details body. `code` is present on business problems, absent on middleware problems. */
5
+ interface ReplyProblem {
6
+ /** Short, human-readable summary. */
7
+ title?: string;
8
+ /** HTTP status code echoed in the body. */
9
+ status?: number;
10
+ /** Human-readable explanation — for DISPLAY only; never classify on this. */
11
+ detail?: string;
12
+ /** Stable, machine-readable slug `<resource>.<variant>` — the classification key. */
13
+ code?: string;
14
+ /** Validation problems add a per-field error array. */
15
+ errors?: unknown;
16
+ }
17
+ /** One per-item failure from a non-atomic (bulk) response dictionary. */
18
+ interface ReplyNotProcessedItem {
19
+ /** camelCase resource-specific failure variant (`contactAlreadyInSequence`, `notFound`, …). */
20
+ error?: string;
21
+ /** Human-readable detail, nullable per the vendor schema. */
22
+ errorDetails?: string | null;
23
+ }
24
+ /**
25
+ * Reply.io connector — extends BaseRESTIntegrationConnector (REST/JSON over HTTP).
26
+ *
27
+ * Discovery, template-var read traversal (second-layer objects resolve their parent through
28
+ * Configuration.parentObjectName), the paginated GET loop and the generic per-operation CRUD dispatch are
29
+ * all INHERITED. This class supplies the Reply-specific protocol surface: Bearer auth, top/skip offset
30
+ * pagination with per-endpoint ceilings, cross-page PK dedupe, embedded-array projection, RFC 9457 problem
31
+ * handling with entitlement (403) separation, non-atomic bulk outcome assertion, and the §7/§10
32
+ * sync-efficiency hooks the frozen contract evidences.
33
+ */
34
+ export declare class ReplyConnector extends BaseRESTIntegrationConnector {
35
+ /** Cached auth for the lifetime of a sync run — Reply API keys are static, there is no refresh step. */
36
+ private cachedAuth;
37
+ /** Last non-2xx problem seen on the wire, used to explain an empty fetch instead of failing silently. */
38
+ private lastProblem;
39
+ /**
40
+ * `Retry-After` (in ms) captured from the most recent 429 seen on the wire, held for exactly ONE read.
41
+ *
42
+ * WHY this exists: the inherited read path validates a non-2xx by throwing a PLAIN `Error` carrying only
43
+ * a status + body preview — the response HEADERS are gone by the time the engine calls
44
+ * `ExtractRetryAfterMs(error)`. Without this capture the vendor's own `Retry-After` instruction is
45
+ * silently discarded on every throttled read and the engine falls back to a generic backoff curve,
46
+ * which is precisely what the contract forbids (100/min AND 3,000/hr are SHARED per user — guessing the
47
+ * wait either wastes the client's quota or hammers a closed window). Captured at the wire boundary,
48
+ * consumed once, then cleared so a stale value can never be replayed against a later error.
49
+ */
50
+ private pendingRetryAfterMs;
51
+ /** Wall-clock ms at which the last STRICT-family (reporting/stats) request was issued. */
52
+ private lastStrictFamilyRequestAt;
53
+ /** Verbatim `MJ: Integrations.Name`. Load-bearing: the T1 three-way check compares this === metadata Name. */
54
+ get IntegrationName(): string;
55
+ get SupportsCreate(): boolean;
56
+ get SupportsUpdate(): boolean;
57
+ get SupportsDelete(): boolean;
58
+ /**
59
+ * Discovery is NON-authoritative. Reply.io publishes no describe-all endpoint (Configuration
60
+ * DiscoveryIsAuthoritative=false in the frozen contract; the 270-path spec has no introspection route),
61
+ * so a refresh can only re-yield what is already Active. Absence in a refresh proves nothing → never
62
+ * deactivate an object on its basis.
63
+ */
64
+ get DiscoveryIsAuthoritative(): boolean;
65
+ /**
66
+ * Sample-union enrichment (MJ connector standard). The Declared metadata is spec-derived and cannot know
67
+ * a tenant's CUSTOM fields (Reply exposes user-defined custom fields on contacts). After the base
68
+ * cache-driven introspection, each object's live read shape is sampled via `DiscoverFieldsViaFetch` and
69
+ * UNIONed into the declared set with `mergeDeclaredWithSampledFields` (never-shrink, declared-wins).
70
+ * Best-effort + parallel — a sample failure (or an unentitled 403 family) leaves the declared set
71
+ * untouched. Overrides `IntrospectSchema`, NOT `DiscoverFields` (that would recurse into
72
+ * `DiscoverFieldsViaFetch`'s own fallback). Connector-agnostic: no Reply-specific field logic.
73
+ */
74
+ IntrospectSchema(companyIntegration: MJCompanyIntegrationEntity, contextUser: UserInfo): Promise<SourceSchemaInfo>;
75
+ /**
76
+ * From every IO's Configuration.rateLimit: 100 requests/minute AND 3,000/hour, SHARED per API user
77
+ * across all of that user's tools. 100/min ≈ 1.67/s; we publish 1.5/s with a small burst so the hourly
78
+ * ceiling (0.83/s sustained) is approached conservatively rather than sprinting into a 429 wall. The
79
+ * engine's AIMD bucket cuts on 429 (honoring Retry-After below) and ramps back slowly.
80
+ */
81
+ get RateLimitPolicy(): RateLimitPolicy | null;
82
+ /**
83
+ * Reply.io returns `Retry-After` (integer SECONDS, minimum 1) on 429 — the vendor's own instruction, so
84
+ * it is honored exactly rather than approximated with a local backoff curve.
85
+ *
86
+ * Two sources, in order: (1) headers carried ON the error, when the caller surfaced a rich error; (2) the
87
+ * value captured at the wire boundary on the last 429, because the inherited read path throws a plain
88
+ * `Error` with the headers already discarded. The captured value is consumed once and cleared, so it can
89
+ * never be replayed against an unrelated later error.
90
+ */
91
+ ExtractRetryAfterMs(error: unknown): number | undefined;
92
+ /** Parses a `Retry-After` header (integer seconds per the vendor) into ms; undefined when absent/unparseable. */
93
+ private retryAfterFromHeaders;
94
+ /**
95
+ * Deliberately LOW. The rate budget is per-user and shared, and the vendor's guidance is sequential /
96
+ * low-concurrency access; parallelism here buys nothing but 429s against a 100/min ceiling.
97
+ */
98
+ get MaxConcurrencyHint(): number | null;
99
+ /**
100
+ * Every object is no-watermark (FullPullHashDiff), so resume relies on the object's declared stable
101
+ * ordering key (the extractor emitted `StableOrderingKey` per IO — usually `id`). Returns null when the
102
+ * object declares none and has no single primary key.
103
+ */
104
+ StableOrderingKey(objectName: string): string | null;
105
+ /**
106
+ * Resolves the Bearer credential from the linked Credential entity (preferred — `apiKey`, per the
107
+ * "API Key" credential type schema) or the CompanyIntegration Configuration JSON (fallback). Cached for
108
+ * the run. There is no token exchange, no signature and no expiry, so no auth-helper grant flow applies.
109
+ */
110
+ protected Authenticate(companyIntegration: MJCompanyIntegrationEntity, contextUser: UserInfo): Promise<RESTAuthContext>;
111
+ /** Static header set — the Bearer value is composed once in Authenticate. */
112
+ protected BuildHeaders(auth: RESTAuthContext): Record<string, string>;
113
+ /**
114
+ * HTTP transport (fetch). Owns the wire boundary; test subclasses override this to capture requests.
115
+ *
116
+ * CRITICAL: a Reply.io 401 is documented to carry an EMPTY BODY (the scheme is signalled by the
117
+ * `WWW-Authenticate` header). Parsing is therefore guarded on a non-empty payload — `JSON.parse('')`
118
+ * on the most common failure path would crash the connector before it could report the auth failure.
119
+ * Any non-2xx is recorded (status + parsed problem) so FetchChanges can explain an empty result.
120
+ */
121
+ protected MakeHTTPRequest(_auth: RESTAuthContext, url: string, method: string, headers: Record<string, string>, body?: unknown): Promise<RESTResponse>;
122
+ /**
123
+ * Extra self-throttle for the reporting / statistics families. The rate budget (100/min AND 3,000/hr) is
124
+ * SHARED per API user across every tool that key drives, and these paths are the expensive ones — so this
125
+ * connector paces them to roughly one request per second rather than letting a stats-heavy object sprint
126
+ * the shared budget into a 429 wall that then stalls the whole sync.
127
+ *
128
+ * Deliberately NOT a retry and NOT a backoff: 429 handling stays with the engine's AIMD bucket +
129
+ * `ExtractRetryAfterMs` (which honors the vendor's `Retry-After` exactly). This is pure pre-emptive
130
+ * spacing, and it is a no-op for every ordinary collection read.
131
+ */
132
+ protected PaceStrictFamily(url: string): Promise<void>;
133
+ /** Clock seam — overridable so a test can assert pacing without real elapsed time. */
134
+ protected NowMs(): number;
135
+ /** Sleep seam — overridable so a test can assert pacing without actually waiting. */
136
+ protected Sleep(ms: number): Promise<void>;
137
+ /**
138
+ * Strips the Reply.io list envelope. Resolution order (deterministic — never "pick any array"):
139
+ * 1. the object's declared ResponseDataKey, when it names an array;
140
+ * 2. a bare array body;
141
+ * 3. the vendor-wide `items` envelope key (documented on every paginated collection);
142
+ * 4. a single non-enveloped object → a one-record array (get-one and the singleton doors such as
143
+ * `/v3/contacts/{id}/statuses`, `/v3/whoami`, `/v3/sequence-templates`).
144
+ */
145
+ protected NormalizeResponse(rawBody: unknown, responseDataKey: string | null): Record<string, unknown>[];
146
+ /**
147
+ * Reply.io pagination is OFFSET ONLY. Continuation is the envelope's explicit `hasMore` boolean; when a
148
+ * response omits it (the non-enveloped singleton doors) a full page is treated as "more may follow" and
149
+ * a short page ends the walk. Cursor / page-number never occur in this vendor's surface.
150
+ */
151
+ protected ExtractPaginationInfo(rawBody: unknown, paginationType: PaginationType, _currentPage: number, currentOffset: number, pageSize: number): PaginationState;
152
+ /**
153
+ * Reply.io offset params are `top` (page size) + `skip` (offset) — NOT the base defaults `limit`/`offset`.
154
+ * The page size is clamped to the endpoint's DECLARED ceiling (IO.DefaultPageSize, probe-confirmed) and
155
+ * to the vendor-wide documented maximum, so a batch-capacity request can never exceed what the endpoint
156
+ * accepts (an over-large `top` is a 400 `*.invalidPagination`, not a silent truncation).
157
+ */
158
+ protected BuildPaginatedURL(basePath: string, obj: MJIntegrationObjectEntity, _page: number, offset: number, _cursor?: string, effectivePageSize?: number): string;
159
+ /** Base host for every request. A Configuration override (sandbox / spec-mock) wins over the vendor host. */
160
+ protected GetBaseURL(_companyIntegration: MJCompanyIntegrationEntity, auth: RESTAuthContext): string;
161
+ /**
162
+ * OVERRIDDEN for three Reply-specific read concerns; everything else delegates to the inherited base
163
+ * GET path (which uses the pagination overrides above and resolves template vars per-parent).
164
+ *
165
+ * 1. EMBEDDED-ARRAY PROJECTION — 17 of the 84 IOs have no endpoint of their own: their records live
166
+ * inside another object's payload, declared as `Configuration.resourceKey = "<Owner>.<key>[]"`
167
+ * (e.g. `Contact.customFields[]`, `HolidayCalendar.holidays[]`, `SequenceStep.variants[]`). For those
168
+ * we fetch the OWNER through the inherited path — so the owner's own pagination, parent-iteration and
169
+ * resume state all apply — then descend the declared key to emit the leaf records.
170
+ * 2. CROSS-PAGE PRIMARY-KEY DEDUPE — offset paging over a mutating collection shifts the window, so the
171
+ * same record can appear on two pages of one walk. Records are de-duplicated by ExternalID.
172
+ * 3. ENTITLEMENT REPORTING — a 403 means "reachable but not entitled" (missing scope / plan feature).
173
+ * The base skips 403 objects with a console.warn; we additionally attach a structured FetchWarning so
174
+ * an unentitled family is REPORTED, not silently dropped and not counted as a pass.
175
+ */
176
+ FetchChanges(ctx: FetchContext): Promise<FetchBatchResult>;
177
+ /**
178
+ * Caps a fetch call to ONE endpoint page so the engine checkpoints after every page.
179
+ *
180
+ * The inherited pagination loop keeps requesting pages until it has accumulated `BatchSize` records,
181
+ * and only THEN returns `NextOffset` for the engine to persist. With Reply.io's 3,000-requests/hour
182
+ * ceiling a first full sync of a large tenant runs for HOURS, and an interruption anywhere inside that
183
+ * loop discards every page fetched since the last engine checkpoint — pages that cost real, shared,
184
+ * per-user quota that cannot be bought back. Clamping the batch to the endpoint's own page ceiling
185
+ * makes the loop return after a single request, so `NextOffset` is persisted per PAGE and a resumed
186
+ * sync re-requests at most one page. Cost: nothing — the same number of HTTP requests either way.
187
+ *
188
+ * No-op for non-offset / non-paginated objects (single-shot doors) and when the engine already asked
189
+ * for a batch at or below the page ceiling.
190
+ */
191
+ protected ClampBatchToOnePage(ctx: FetchContext, walked: MJIntegrationObjectEntity): FetchContext;
192
+ /**
193
+ * Reads the embedded-projection declaration off an IO. The extractor writes
194
+ * `Configuration.resourceKey = "<OwnerObjectName>.<nestedKey>[]"` for an object that is an array nested
195
+ * inside another payload, and a plain slash-path (`"contacts/statuses"`) for one that has its own
196
+ * endpoint — so the two cases are distinguished by DECLARED metadata, never by guessing at the URL.
197
+ */
198
+ private resolveEmbeddedProjection;
199
+ /**
200
+ * Fetches an embedded-projection object: pull the CONTAINER records through the inherited fetch path,
201
+ * then descend the declared nested key on each one to emit the leaf records.
202
+ *
203
+ * The container is the OWNER object named in `resourceKey` when that object exists and shares this
204
+ * object's APIPath (so the owner's declared pagination/parent-iteration governs the walk); otherwise —
205
+ * e.g. the sequence-template groups, whose owner is declared as `null` — the projection's own IO row is
206
+ * the container. Both cases read only DECLARED metadata.
207
+ */
208
+ private fetchProjected;
209
+ /** The IO whose endpoint actually returns the container payload for a projection (owner, else self). */
210
+ private resolveContainerObject;
211
+ /**
212
+ * Copies owner-record keys down onto a leaf record — but ONLY keys the leaf object DECLARES as its own
213
+ * fields and does not already carry. This links a nested row back to its owner (the parent id the base
214
+ * tagged onto the container) without inventing columns: an undeclared owner key is never copied.
215
+ */
216
+ private inheritDeclaredOwnerKeys;
217
+ /**
218
+ * Drops repeats of the same primary key WITHIN one fetch batch — which is exactly "across pages", since
219
+ * the inherited loop accumulates every page of the walk into one batch. Records with no resolvable
220
+ * ExternalID are passed through untouched (they cannot be compared, and silently collapsing them would
221
+ * lose data). Across BATCHES the engine's content-hash idempotency makes a repeat a no-op update.
222
+ */
223
+ private dedupeByExternalID;
224
+ /**
225
+ * Turns an observed 403/401 into a structured warning when the fetch produced nothing, so an unentitled
226
+ * or unauthenticated family is REPORTED rather than read as "this object is legitimately empty".
227
+ * 403 is explicitly NOT an invalid credential and NOT a connector defect.
228
+ */
229
+ private collectWarnings;
230
+ /**
231
+ * Diagnoses a parent-iterated object whose DECLARED parent cannot actually yield the ids its path
232
+ * needs, and reports it as a structured warning.
233
+ *
234
+ * This does NOT repair the declaration — a wrong `Configuration.parentObjectName` is an upstream
235
+ * metadata defect that belongs in the extractor's amendment loop, and guessing a replacement here is
236
+ * exactly the silent cross-owner corruption the base class refuses to commit. What it prevents is the
237
+ * WORSE outcome: such an object fetches zero rows (the parent yields no usable ids) and, with no
238
+ * warning attached, that empty result is indistinguishable from "this object is legitimately empty" —
239
+ * a green sync that quietly carries nothing.
240
+ *
241
+ * Two detectable defects, both provable from metadata alone:
242
+ * - a MULTI-var path (`/v3/sequences/{sequence_id}/contacts/{contact_id}/preview`) with only the
243
+ * single-valued `parentObjectName` — both vars would resolve to the same parent, which the base
244
+ * rejects as a dependency cycle;
245
+ * - a declared parent that is missing, or that declares NO primary key (e.g. an embedded projection
246
+ * such as `ContactCustomField`, whose fields are `key`/`value`) — there is no id column to iterate.
247
+ */
248
+ private parentDeclarationWarning;
249
+ /** True when the IO's Configuration carries KEY as a non-empty object (used for the per-var parent map). */
250
+ private hasConfigObject;
251
+ /** Create — generic per-operation dispatch + parent-var substitution + partial-success assertion. */
252
+ CreateRecord(ctx: CreateRecordContext): Promise<CRUDResult>;
253
+ /** Update — generic per-operation dispatch (PUT or PATCH per IO) + partial-success assertion. */
254
+ UpdateRecord(ctx: UpdateRecordContext): Promise<CRUDResult>;
255
+ /** Delete — generic per-operation dispatch (hard delete; the vendor has no universal tombstone). */
256
+ DeleteRecord(ctx: DeleteRecordContext): Promise<CRUDResult>;
257
+ /** Authenticates, resolves the URL and fires ONE write request. No retry — see the CRUD section note. */
258
+ private executeWrite;
259
+ /**
260
+ * Asserts a write actually happened. Returns a failure CRUDResult, or null when the write succeeded.
261
+ *
262
+ * Order matters: a non-2xx is a failure classified off the problem CODE; a 2xx is only a success once the
263
+ * body has been checked for the non-atomic per-item failure dictionary. HTTP 200 alone proves the request
264
+ * was ACCEPTED, not that the item was PROCESSED — the two are different on this vendor.
265
+ */
266
+ private assertWriteOutcome;
267
+ /**
268
+ * Parses a non-atomic (bulk) response body: a dictionary keyed by item id whose values are
269
+ * `{ error, errorDetails }` — ONLY failed items appear, and `{}` means everything succeeded. Returns null
270
+ * when the body is not that shape (an ordinary single-record response), so this check costs nothing on
271
+ * the normal path and can never misread a created record as a failure.
272
+ */
273
+ parseNotProcessed(body: unknown): Map<string, ReplyNotProcessedItem> | null;
274
+ /**
275
+ * OVERRIDDEN so the generic Update/Delete/Get path can template Reply's NAMED and NESTED path vars
276
+ * (`{step_id}`, `{variant_id}`, `{knowledge_base_id}`, `{document_id}`, `{tagId}`) — the base substitutes
277
+ * only `{id}`/`{ExternalID}`. A single-var path takes the whole ExternalID; a multi-var (nested) path
278
+ * takes the composite `parent|child` ExternalID split in path order.
279
+ */
280
+ protected SubstituteIDInPath(path: string, externalID: string, idLocation: string | null): string;
281
+ /** Fills a create path's PARENT template vars from the record's own attributes (nested creates). */
282
+ private substitutePathVarsFromAttributes;
283
+ /**
284
+ * OVERRIDDEN for the RFC 9457 `application/problem+json` envelope. Never assumes a body exists — a
285
+ * Reply.io 401 carries none. The message leads with the STABLE machine `code` so the engine's classifier
286
+ * and any human reader both key off the slug rather than the localized `detail`.
287
+ */
288
+ protected ExtractErrorMessage(response: RESTResponse): string | undefined;
289
+ /** Parses an RFC 9457 problem body; null for an empty/non-problem payload (401 has NO body). */
290
+ parseProblem(body: unknown): ReplyProblem | null;
291
+ /**
292
+ * Maps a Reply.io response to a `SyncErrorCode` using the STABLE machine slug first and the HTTP status
293
+ * as the fallback — never the human-readable `detail`, which is localized prose and free to change.
294
+ *
295
+ * 403 maps to CONFIGURATION_ERROR (an entitlement/scope gap the operator resolves), deliberately NOT to
296
+ * an auth failure: the credential is valid, the tenant simply is not entitled to that surface.
297
+ */
298
+ ClassifyProblem(status: number, problem: ReplyProblem | null): SyncErrorCode;
299
+ /**
300
+ * Renders a problem into a message that (a) leads with the machine slug, (b) states the classification,
301
+ * and (c) carries wording the engine's message-based `ClassifyError` maps to the SAME SyncErrorCode —
302
+ * so the code the connector determined survives the string boundary instead of being re-guessed.
303
+ */
304
+ private describeProblem;
305
+ /** A phrase the engine's message-based ClassifyError maps to the given code (keeps both paths in sync). */
306
+ private classifierHint;
307
+ /**
308
+ * Records the last non-2xx seen on the wire so an empty fetch can be explained rather than guessed at,
309
+ * and captures a 429's `Retry-After` before the inherited validator throws it away (see
310
+ * {@link pendingRetryAfterMs}).
311
+ */
312
+ private recordProblem;
313
+ /**
314
+ * Verifies the credential against `GET /v3/whoami` — documented `x-required-scope: none`, so ANY valid
315
+ * key can call it and a failure there is unambiguous. 401 = bad/missing key (empty body by design);
316
+ * 403 on this endpoint would still mean the key is valid but the account is restricted, so it is reported
317
+ * as a NON-credential problem.
318
+ */
319
+ TestConnection(companyIntegration: MJCompanyIntegrationEntity, contextUser: UserInfo): Promise<ConnectionTestResult>;
320
+ /** Reads the API key from the linked Credential entity, falling back to the Configuration JSON. */
321
+ private loadCredentials;
322
+ /** Loads a credential row and parses its Values JSON. */
323
+ private loadFromCredentialEntity;
324
+ /** Extracts the API key from a credential/config JSON string (tolerant of absent/invalid). */
325
+ private parseCredentialJson;
326
+ /**
327
+ * Reads an explicit base-URL override from the connection Configuration. Only an absolute http(s) URL is
328
+ * honored, so a stray value cannot misroute a production tenant. Null (the normal case) → the vendor host.
329
+ */
330
+ private resolveBaseURLOverride;
331
+ /** Joins a base URL and a path (mirrors the base's private BuildFullURL). */
332
+ private joinURL;
333
+ /** PK field names in declared Sequence order; empty when the object declares none. */
334
+ private pkFieldNames;
335
+ /**
336
+ * Builds an ExternalRecord whose Fields carry the FULL source record (full-record pass-through — the
337
+ * framework's custom-column capture diffs keys(Fields) against the active field maps, so a narrowed
338
+ * literal would make custom columns permanently invisible). ExternalID is the composite of the declared
339
+ * PK fields when all are present, else the vendor's universal `id`, else empty.
340
+ */
341
+ private buildExternalRecord;
342
+ /**
343
+ * Gets an IO by NAME alone from the engine cache, without throwing (callers may run before the cache is
344
+ * warm, and StableOrderingKey has no IntegrationID to hand). Protected so a test/mock subclass can
345
+ * substitute fixture rows exactly as it does for GetCachedObject.
346
+ */
347
+ protected tryGetCachedObject(objectName: string): MJIntegrationObjectEntity | null;
348
+ /** Reads a trimmed string value from an IntegrationObject's Configuration JSON (tolerant of absent/invalid). */
349
+ private readConfigString;
350
+ /** Returns the first present, non-empty string value among the given keys. */
351
+ private firstString;
352
+ /** Best-effort extraction of response headers from an error object (for ExtractRetryAfterMs). */
353
+ private extractHeadersFromError;
354
+ }
355
+ export {};