@memberjunction/connector-eventbrite 1.1.0 → 2.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.
@@ -1,98 +1,36 @@
1
1
  import { type UserInfo } from '@memberjunction/core';
2
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 SourceSchemaInfo } from '@memberjunction/integration-engine';
3
+ import { BaseRESTIntegrationConnector, type RESTAuthContext, type RESTResponse, type PaginationState, type PaginationType, type ConnectionTestResult, type FetchContext, type FetchBatchResult, type RateLimitPolicy, type CreateRecordContext, type UpdateRecordContext, type DeleteRecordContext, type CRUDResult, type SourceSchemaInfo } from '@memberjunction/integration-engine';
4
4
  /**
5
- * Per-connection configuration for the Eventbrite Platform REST API v3 connector.
5
+ * Eventbrite events/ticketing connector extends BaseRESTIntegrationConnector (REST/JSON over HTTP).
6
6
  *
7
- * Eventbrite has a single shared host (`https://www.eventbriteapi.com/v3`). Auth is
8
- * a Bearer token either a long-lived private token (issued from the account API Keys
9
- * page) or an OAuth2 authorization-code access token. Both are used verbatim as
10
- * `Authorization: Bearer <token>`; the connector does not perform the token exchange
11
- * (the resolved token lives in the credential store).
7
+ * Discovery, template-var parent traversal, and the paginated GET loop are inherited. This class supplies
8
+ * only the Eventbrite-specific protocol surface: Bearer auth, the continuation-token cursor, the
9
+ * `changed_since` incremental pull, connection testing, the write path (vendor-named path vars), and the
10
+ * §7/§10 sync-efficiency hooks the frozen contract evidences.
12
11
  */
13
- export interface EventbriteConnectionConfig {
14
- /**
15
- * The resolved Bearer token (private token OR OAuth2 access token). From the credential
16
- * store (keys PrivateToken / Token / AccessToken / apiKey). Secrets never live in code.
17
- */
18
- Token?: string;
12
+ export declare class EventbriteConnector extends BaseRESTIntegrationConnector {
13
+ /** Cached auth for the lifetime of a single sync run (Eventbrite tokens are long-lived, no refresh). */
14
+ private cachedAuth;
19
15
  /**
20
- * Non-secret API host override (e.g. a local mock for replay/contract testing). When set,
21
- * it WINS over the production host. Mirrors the ORCID/Novi `ApiBaseUrl` pattern required
22
- * for the mock-floor e2e testability tier.
16
+ * The active incremental watermark for the object currently being fetched, stashed by the FetchChanges
17
+ * override so the (ctx-less) BuildPaginatedURL can append `changed_since`. Set at the top of an
18
+ * incremental FetchChanges, cleared in its finally. Safe because the engine drives one FetchChanges per
19
+ * object at a time (single-threaded async; no concurrent BuildPaginatedURL for a different watermark).
23
20
  */
24
- ApiBaseUrl?: string;
25
- /** HTTP request timeout in milliseconds. Default: 30000. */
26
- RequestTimeoutMs?: number;
27
- /** Maximum retries for rate-limited / transient failures. Default: 4. */
28
- MaxRetries?: number;
29
- /** Minimum interval between outbound requests (ms). Default: 100. */
30
- MinRequestIntervalMs?: number;
31
- }
32
- /**
33
- * Connector for the Eventbrite Platform REST API v3.
34
- *
35
- * Authenticates via a Bearer token (private token or OAuth2 access token, resolved
36
- * upstream and held in the credential store). Reads ride the base
37
- * {@link BaseRESTIntegrationConnector} pull path; this class overrides only the
38
- * genuinely Eventbrite-specific bits:
39
- * - {@link BuildHeaders}: `Authorization: Bearer <token>` + `Accept: application/json`.
40
- * - {@link GetBaseURL}: the shared `https://www.eventbriteapi.com/v3` host (or a
41
- * non-secret `ApiBaseUrl` override for mock testing).
42
- * - {@link NormalizeResponse}: unwraps the named-key list envelope (the IO's
43
- * `ResponseDataKey`, e.g. `events`/`attendees`/`orders`) which sits alongside a
44
- * `pagination` object; a bare object is a one-record detail; null → [].
45
- * - {@link ExtractPaginationInfo} / {@link BuildPaginatedURL}: Eventbrite continuation-cursor
46
- * pagination (`?continuation=<token>` + `pagination.has_more_items`/`.continuation`),
47
- * plus the `changed_since=<watermark>` param for incremental objects (Order/Attendee) —
48
- * fully metadata-driven (read `obj.IncrementalWatermarkField` + `obj.SupportsIncrementalSync`),
49
- * NEVER keyed off a hardcoded object name.
50
- * - {@link FetchChanges}: sets the watermark context, delegates to the base, then advances
51
- * the watermark from the records' `changed` field on the final batch only (HasMore=false)
52
- * so a partial-failure mid-pagination leaves the watermark unchanged.
53
- *
54
- * Create/Update/Delete use the generic per-operation column path
55
- * (CreateAPIPath/Method/BodyShape/BodyKey/IDLocation, Update*, Delete*) driven entirely
56
- * by the IO metadata — no per-verb override. Eventbrite uses POST for update (its
57
- * convention) and wrapped bodies for most resources (CreateBodyKey =
58
- * event|ticket_class|venue|discount|question|webhook|...). Parent template vars
59
- * ({organization_id}/{event_id}) are resolved by the engine's parent-iteration from each
60
- * IO's `Configuration.parentObjectName`.
61
- */
62
- export declare class EventbriteConnector extends BaseRESTIntegrationConnector {
63
- /** Cached auth context (token + resolved host). */
64
- private authState;
65
- /** Timestamp of the last outbound request, used for throttling. */
66
- private lastRequestTime;
67
- /** Watermark for the current FetchChanges cycle, emitted as the IO's incremental param. */
68
- private currentWatermark;
69
- /** Verbatim from the metadata Integration row — part of the three-way name invariant. */
21
+ private activeChangedSince;
22
+ /** Verbatim `MJ: Integrations.Name`. Load-bearing: the T1 three-way name check compares this === metadata Name. */
70
23
  get IntegrationName(): string;
71
24
  get SupportsCreate(): boolean;
72
25
  get SupportsUpdate(): boolean;
73
26
  get SupportsDelete(): boolean;
74
27
  /**
75
- * Eventbrite documents a default 2,000 calls/hour per token (HTTP 429 HIT_RATE_LIMIT
76
- * over it). Run under that ceiling; the engine's AIMD bucket throttles + backs off.
77
- */
78
- get RateLimitPolicy(): RateLimitPolicy | null;
79
- /**
80
- * Cap how many EVENT/ORG parents a second-layer object (TicketClass, Attendee, Question,
81
- * EventTeam, InventoryTier, …) iterates per FetchChanges call. At Eventbrite's deliberately
82
- * conservative 0.27 tok/s (~3.7 s/request once the SHARED burst is spent by earlier objects in
83
- * the same sync), the base default of 10 parents costs ~37 s and blows the 30 s FetchChanges
84
- * op-timeout — the batch is then abandoned and the object syncs 0 records for any org with that
85
- * many events. A batch of 4 costs ~15 s (well under 30 s) and the engine resumes the remaining
86
- * parents via HasMore/keyset, so a high-event-count org still syncs completely.
28
+ * Discovery is NON-authoritative: DiscoverObjects / IntrospectSchema are cache-driven (they re-read
29
+ * persisted ACTIVE Declared metadata, NOT a live full-gamut enumeration). Eventbrite publishes no
30
+ * schema/describe/introspection endpoint enumerating everything a credential can access, so absence in a
31
+ * refresh proves nothing → never deactivate. Matches Configuration.DiscoveryIsAuthoritative=false.
87
32
  */
88
- protected TemplateVarParentBatchSize(): number;
89
- /** Parse Eventbrite's Retry-After header (delta-seconds or HTTP-date) into milliseconds. */
90
- ExtractRetryAfterMs(error: unknown): number | undefined;
91
- /**
92
- * Verifies connectivity via `GET /users/me/`. A 2xx confirms the Bearer token is valid;
93
- * a 401/403 means the token was rejected (NOT_AUTH / NOT_PERMITTED).
94
- */
95
- TestConnection(companyIntegration: MJCompanyIntegrationEntity, contextUser: UserInfo): Promise<ConnectionTestResult>;
33
+ get DiscoveryIsAuthoritative(): boolean;
96
34
  /**
97
35
  * IntrospectSchema — pure WIRING of MJ's existing sampler into the declared catalog (the connector
98
36
  * sample-union standard; see CONNECTOR_DISCOVERY_STANDARD.md). This connector adds NO discovery,
@@ -113,85 +51,154 @@ export declare class EventbriteConnector extends BaseRESTIntegrationConnector {
113
51
  */
114
52
  IntrospectSchema(companyIntegration: MJCompanyIntegrationEntity, contextUser: UserInfo): Promise<SourceSchemaInfo>;
115
53
  /**
116
- * Sets the watermark context that {@link BuildPaginatedURL} emits as the IO's
117
- * incremental `changed_since` param (for Order/Attendee), delegates the actual fetch +
118
- * cursor pagination to the base, then advances the watermark from the returned records'
119
- * `changed` field on the FINAL batch only (HasMore=false) so a partial-failure
120
- * mid-pagination leaves the watermark unchanged.
54
+ * From Configuration.RateLimitPolicy: 2,000 calls/hour per token (blueprint ## Errors, 429
55
+ * HIT_RATE_LIMIT). 2000/3600 0.556 tokens/sec sustained. Burst kept small (the hourly ceiling is the
56
+ * real constraint; there is no documented per-second burst allowance).
121
57
  */
122
- FetchChanges(ctx: FetchContext): Promise<FetchBatchResult>;
58
+ get RateLimitPolicy(): RateLimitPolicy | null;
59
+ /**
60
+ * The frozen contract records NO Retry-After header shape for Eventbrite's 429 (HIT_RATE_LIMIT)
61
+ * (Configuration.RateLimitPolicy.retryAfterHeaderDocumented=false), so there is nothing to parse
62
+ * reliably. Left as the base default (undefined) rather than guessing a header name — the engine's AIMD
63
+ * bucket backs off on the 429 regardless. Documented as a soft gap for live-probe confirmation.
64
+ */
65
+ /** Conservative in-flight cap. The 2,000/hour ceiling is the real limiter; a low cap avoids bursts. */
66
+ get MaxConcurrencyHint(): number | null;
67
+ /**
68
+ * No-watermark objects resume by their StableOrderingKey — read from the IO metadata when the extractor
69
+ * emitted one, else the object's PK (Eventbrite's universal `id`). Returns null when the object has no
70
+ * stable key or the cache is unavailable (unit-test context).
71
+ */
72
+ StableOrderingKey(objectName: string): string | null;
123
73
  /**
124
- * Scans a batch for the latest watermark value. Eventbrite's incremental objects
125
- * (Order, Attendee) carry a `changed` ISO-8601 timestamp per record (the field named by
126
- * the IO's `IncrementalWatermarkField`); we take the max so the next run's `changed_since`
127
- * resumes from there.
74
+ * Resolves the pre-minted Bearer token from the linked Credential entity (preferred) or the
75
+ * CompanyIntegration Configuration JSON (fallback). Cached for the run.
128
76
  */
129
- private ExtractLatestWatermark;
130
77
  protected Authenticate(companyIntegration: MJCompanyIntegrationEntity, contextUser: UserInfo): Promise<RESTAuthContext>;
131
- /** Builds the Eventbrite auth header: `Authorization: Bearer <token>` + `Accept: application/json`. */
78
+ /** Eventbrite OAuth2 auth: a pre-minted Bearer token. No signing, no crypto. */
132
79
  protected BuildHeaders(auth: RESTAuthContext): Record<string, string>;
80
+ /** HTTP transport (fetch). Owns the wire boundary; test subclasses override this to capture requests. */
81
+ protected MakeHTTPRequest(_auth: RESTAuthContext, url: string, method: string, headers: Record<string, string>, body?: unknown): Promise<RESTResponse>;
133
82
  /**
134
- * Unwraps Eventbrite's response shapes:
135
- * - List endpoints: `{ <responseDataKey>: [ ... ], pagination: { ... } }` → the named array.
136
- * - Single-record detail (no responseDataKey): a bare object a one-element array.
137
- * - null [].
138
- * The IO metadata's `responseDataKey` (e.g. `events`/`attendees`/`orders`) is the
139
- * authoritative envelope key; when absent the body is treated as a bare detail object.
83
+ * Strips the Eventbrite list envelope. Each list endpoint nests records under a plural snake_case
84
+ * resource key (`events`, `attendees`, `orders`, …), stored per-IO as ResponseDataKey. When
85
+ * ResponseDataKey is unset (the extractor left it null), the key is DERIVED from the last non-templated
86
+ * path segment of the APIPath — but NormalizeResponse doesn't have the IO here, so the fallback scans the
87
+ * envelope for the sole array-valued key alongside `pagination`. A bare-array or single-object body (the
88
+ * get-one / non-paginated shape) is handled directly.
140
89
  */
141
90
  protected NormalizeResponse(rawBody: unknown, responseDataKey: string | null): Record<string, unknown>[];
142
- private coerceToArray;
143
- private isRecord;
144
91
  /**
145
- * Continuation-cursor pagination: Eventbrite returns a `pagination` envelope carrying
146
- * `has_more_items` (boolean) and `continuation` (opaque cursor token). There are more
147
- * records while `has_more_items` is true AND a continuation token is present. The
148
- * total record count (`object_count`) is surfaced when available.
149
- *
150
- * The `Cursor` pagination type drives this; non-cursor objects (`None`) report no more.
92
+ * Eventbrite pagination is the CONTINUATION-TOKEN scheme (NOT page-number/offset). Read
93
+ * `pagination.has_more_items` and `pagination.continuation` from the envelope. When has_more_items is
94
+ * true AND a continuation token is present, the next page is requested with that token; otherwise the set
95
+ * is exhausted. Source: Configuration.PaginationDefaults.advanceProtocol.
96
+ * currentPage/offset/pageSize are unused for continuation pagination.
97
+ */
98
+ protected ExtractPaginationInfo(rawBody: unknown, _paginationType: PaginationType, _currentPage: number, _currentOffset: number, _pageSize: number): PaginationState;
99
+ /**
100
+ * Eventbrite v3 host. Defaults to the fixed `https://www.eventbriteapi.com/v3` host (the version
101
+ * segment is part of the base URL; metadata APIPaths are relative). Honors a
102
+ * `CompanyIntegration.Configuration.BaseURL` override when present — used for region redirects and
103
+ * for pointing the connector at a mock ORIGIN server in credential-free e2e testing without touching
104
+ * the vendor's real endpoint. Falls back to the fixed host on absence or malformed Configuration.
151
105
  */
152
- protected ExtractPaginationInfo(rawBody: unknown, paginationType: PaginationType, currentPage: number, currentOffset: number, _pageSize: number): PaginationState;
106
+ protected GetBaseURL(companyIntegration?: MJCompanyIntegrationEntity): string;
153
107
  /**
154
- * Builds the paginated request URL. Eventbrite cursor pagination uses `?continuation=<token>`
155
- * (the base default emits `?cursor=...&limit=...`), so we override to emit the vendor's
156
- * param. Also emits the IO's `IncrementalWatermarkField` as `changed_since=<watermark>`
157
- * when this is an incremental object (Order/Attendee) and a watermark is in context —
158
- * fully metadata-driven (read `obj.IncrementalWatermarkField` + `obj.SupportsIncrementalSync`),
159
- * NEVER keyed off a hardcoded object name.
108
+ * Eventbrite pages via the `continuation` query param (NOT the base default `cursor=`). The first page
109
+ * sends no continuation token; subsequent pages send `continuation=<token>`. When an incremental
110
+ * watermark is active (Attendee/Order this run), `changed_since=<watermark>` is appended so the API
111
+ * returns only records changed after the watermark. Eventbrite has no client-controlled page-size param
112
+ * on these list endpoints (page_size is server-fixed and reported in the envelope), so no limit is sent.
113
+ */
114
+ protected BuildPaginatedURL(basePath: string, _obj: MJIntegrationObjectEntity, _page: number, _offset: number, cursor?: string, _effectivePageSize?: number): string;
115
+ /**
116
+ * OVERRIDDEN to (1) inject the `changed_since` param for the two incremental objects (Attendee, Order —
117
+ * SupportsIncrementalSync + a watermark this run) and (2) EMIT the new watermark. The base flat/template
118
+ * fetch path threads no watermark into the URL and returns no NewWatermarkValue, so the connector owns
119
+ * both. All fetching (continuation pagination, template-var parent traversal) is delegated to the base;
120
+ * this override only sets activeChangedSince around the super call and computes the watermark on a fully
121
+ * drained batch.
160
122
  *
161
- * Parent template vars ({organization_id}/{event_id}) are NOT substituted here those are
162
- * resolved by the engine's parent-iteration from the IO's `Configuration.parentObjectName`.
163
- */
164
- protected BuildPaginatedURL(basePath: string, obj: MJIntegrationObjectEntity, _page: number, _offset: number, cursor?: string, _effectivePageSize?: number): string;
165
- /**
166
- * Resolves the vendor-side query param name for an incremental object. Eventbrite's
167
- * incremental filter param is `changed_since` (the IO's `IncrementalWatermarkField` is the
168
- * RECORD field `changed`). Metadata-driven: only objects whose `SupportsIncrementalSync` is
169
- * true and whose watermark field is set receive the param.
170
- */
171
- private resolveWatermarkParam;
172
- protected GetBaseURL(_companyIntegration: MJCompanyIntegrationEntity, auth: RESTAuthContext): string;
173
- protected MakeHTTPRequest(auth: RESTAuthContext, url: string, method: string, headers: Record<string, string>, body?: unknown): Promise<RESTResponse>;
174
- /** Single fetch() with an AbortController-backed timeout. */
175
- private doFetch;
176
- private safeParseJSON;
177
- private isRetryableError;
178
- private backoffMs;
179
- private backoffFromResponse;
180
- private throttle;
181
- private sleep;
182
- private asObject;
183
- /**
184
- * Resolves the connection config: the Bearer token from the credential store; the
185
- * non-secret host override + transport tunables from CompanyIntegration.Configuration.
186
- * Secrets are NEVER baked into code.
187
- */
188
- private parseConfig;
189
- /** Resolves the API host: the non-secret override wins over the production host. */
190
- private resolveBaseUrl;
191
- /** Parses the non-secret host/tunables config from CompanyIntegration.Configuration JSON. */
192
- private parseConfigurationJson;
193
- /** Loads the Bearer token from the MJ credential store. */
194
- private loadFromCredential;
123
+ * Partial-failure safety: NewWatermarkValue is emitted ONLY when the whole object is drained
124
+ * (HasMore=false). A mid-stream batch (HasMore=true more parents to iterate) advances no watermark, so a
125
+ * failure between batches resumes from the unchanged prior watermark. When the final batch's max `changed`
126
+ * is below an earlier batch's, the watermark under-advances (worst case re-fetches already-seen records
127
+ * next run — idempotent, safe) rather than skipping records (data loss).
128
+ */
129
+ FetchChanges(ctx: FetchContext): Promise<FetchBatchResult>;
130
+ /**
131
+ * Create: substitutes the create path's parent vars from Attributes/Relationships (create paths carry
132
+ * only parent vars — the record has no id yet), POSTs the body shaped per CreateBodyShape/CreateBodyKey,
133
+ * and routes the result through BuildCreatedResult so a 2xx with no usable id FAILS LOUDLY.
134
+ */
135
+ CreateRecord(ctx: CreateRecordContext): Promise<CRUDResult>;
136
+ /**
137
+ * Update: substitutes the record's own id (ExternalID) into the LAST path var and any parent vars from
138
+ * Attributes/Relationships, then POSTs the wrapped body (Eventbrite uses POST, not PATCH/PUT, for update).
139
+ */
140
+ UpdateRecord(ctx: UpdateRecordContext): Promise<CRUDResult>;
141
+ /**
142
+ * Delete: substitutes the record's own id (ExternalID) into the last path var and any parent vars, then
143
+ * issues DeleteMethod (metadata-driven — Eventbrite uses hard DELETE, but the verb is read from metadata,
144
+ * not assumed).
145
+ */
146
+ DeleteRecord(ctx: DeleteRecordContext): Promise<CRUDResult>;
147
+ /**
148
+ * Tests the connection by hitting the current-user endpoint (`/users/me/`). A 2xx confirms the Bearer
149
+ * token is valid; 401/403 → auth failure; anything else → error.
150
+ */
151
+ TestConnection(companyIntegration: MJCompanyIntegrationEntity, contextUser: UserInfo): Promise<ConnectionTestResult>;
152
+ /** Reads the Bearer token from the linked Credential entity, or the Configuration JSON fallback. */
153
+ private LoadCredentials;
154
+ /** Loads a credential row and parses its Values JSON. */
155
+ private LoadFromCredentialEntity;
156
+ /** Extracts an Eventbrite token from a credential/config JSON string. Returns null when no token is present. */
157
+ private ParseCredentialJson;
158
+ /**
159
+ * Substitutes EVERY `{var}` in a write path. The record's own id (recordID, when provided) fills the
160
+ * generic `{ID}`/`{id}`/`{ExternalID}` placeholders AND the LAST vendor-named var in the path (the
161
+ * record's own id segment for update/delete, e.g. `{ticket_class_id}` in
162
+ * `/events/{event_id}/ticket_classes/{ticket_class_id}/`). Every OTHER var is a parent id resolved from
163
+ * the resolution map (Attributes ∪ Relationships), matched case-insensitively. Returns the resolved path
164
+ * plus the list of vars that could NOT be resolved (caller fails loudly on a non-empty list).
165
+ */
166
+ private SubstituteAllPathVars;
167
+ /**
168
+ * A delete carries no Attributes, so a child-object delete path with a parent var (e.g.
169
+ * `/events/{event_id}/ticket_classes/{...}`) has no place to source the parent id — EXCEPT the composite
170
+ * ExternalID. When the path has >1 var, the ExternalID is expected as `parentId|...|recordId` (the base's
171
+ * composite-PK ExternalID form): the trailing segment is the record id, the leading segments fill the
172
+ * parent vars in path order. When the path has ≤1 var, the whole ExternalID is the record id.
173
+ */
174
+ private SplitCompositeExternalID;
175
+ /** Detects `{var}` placeholders in a path. */
176
+ private DetectPathVars;
177
+ /** Builds a case-insensitive lookup map of parent-id candidates from Attributes ∪ Relationships. */
178
+ private BuildResolutionMap;
179
+ /** Builds a consistent unresolved-var CRUD failure result (never a broken URL to the wire). */
180
+ private UnresolvedVarError;
181
+ /**
182
+ * Formats a watermark value into the `changed_since` datetime the API expects (UTC ISO-8601). Accepts an
183
+ * ISO string (passed through) or an epoch-ms string (converted). Eventbrite documents `changed_since` as
184
+ * a UTC datetime; passing an unparseable value through unchanged lets the API reject it loudly rather than
185
+ * silently widening the window.
186
+ */
187
+ private FormatChangedSince;
188
+ /**
189
+ * Computes the new watermark = the max `changed` timestamp across the fetched records, floored at the
190
+ * incoming watermark so it never regresses. Records that don't carry the field are skipped. Returns the
191
+ * incoming watermark unchanged when no record advances it (idempotent no-op next run).
192
+ */
193
+ private MaxWatermark;
194
+ /** Parses a watermark (ISO datetime or epoch-ms string) to epoch ms; 0 when unparseable/empty. */
195
+ private ToMs;
196
+ /** Returns the first present, non-empty string value among the given keys. */
197
+ private FirstString;
198
+ /**
199
+ * Resolves an IO by name from the engine cache (via this integration's id) without throwing. Returns null
200
+ * when the cache is unavailable (unit-test context) or the object isn't found — StableOrderingKey then
201
+ * degrades to null, a safe default (the engine simply doesn't use keyset resume for that object).
202
+ */
203
+ private TryGetActiveObject;
195
204
  }
196
- /** Tree-shaking prevention function — import and call from the package entry point. */
197
- export declare function LoadEventbriteConnector(): void;