@memberjunction/connector-eventbrite 1.0.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.
@@ -6,483 +6,637 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
6
6
  };
7
7
  import { RegisterClass } from '@memberjunction/global';
8
8
  import { Metadata } from '@memberjunction/core';
9
- import { BaseIntegrationConnector, BaseRESTIntegrationConnector, } from '@memberjunction/integration-engine';
9
+ import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
10
+ import { BaseIntegrationConnector, BaseRESTIntegrationConnector,
11
+ // NOTE: no auth-helper crypto/token-exchange imported. Eventbrite auth is a PRE-MINTED, long-lived
12
+ // Bearer token (the account "Private Token" from the API Keys page, or the access token minted once by
13
+ // the OAuth2 authorization-code exchange). The frozen contract documents NO refresh_token grant and NO
14
+ // TTL (Configuration.TokenRefreshStrategy = null), so there is no in-connector token round-trip to run —
15
+ // OAuth2TokenManager would fabricate an undocumented exchange. Every documented request sends the token
16
+ // verbatim as `Authorization: Bearer <token>`, exactly like a static Bearer key. No signing, no crypto.
17
+ } from '@memberjunction/integration-engine';
18
+ import { mergeDeclaredWithSampledFields } from '@memberjunction/connector-schema-merge';
10
19
  // ─── Constants ────────────────────────────────────────────────────────
11
- const PROD_API_HOST = 'https://www.eventbriteapi.com/v3';
12
- const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
13
- const DEFAULT_MAX_RETRIES = 4;
14
- const DEFAULT_MIN_REQUEST_INTERVAL_MS = 100;
15
20
  /**
16
- * Eventbrite documents a default rate limit of 2,000 calls per hour per token
17
- * (≈0.55/s). Run conservatively under that: ~0.27 tokens/s with a small burst so the
18
- * engine's AIMD bucket throttles + backs off on the 429 HIT_RATE_LIMIT error.
21
+ * Eventbrite API host with the v3 version segment embedded in the path (Configuration.APIVersioningNote —
22
+ * HOST directive `https://www.eventbriteapi.com/v3/`). Every IO's APIPath is relative to this v3 host.
19
23
  */
20
- const RATE_LIMIT_TOKENS_PER_SEC = 0.27;
21
- const RATE_LIMIT_BURST = 10;
22
- // ─── Connector implementation ─────────────────────────────────────────
24
+ const EVENTBRITE_API_BASE = 'https://www.eventbriteapi.com/v3';
25
+ /** Query param carrying the continuation cursor (Configuration.PaginationDefaults.continuationParam). */
26
+ const CONTINUATION_PARAM = 'continuation';
27
+ /** Query param carrying the incremental datetime cursor for Attendee + Order. */
28
+ const CHANGED_SINCE_PARAM = 'changed_since';
29
+ // ─── EventbriteConnector ───────────────────────────────────────────────
23
30
  /**
24
- * Connector for the Eventbrite Platform REST API v3.
31
+ * Eventbrite events/ticketing connector extends BaseRESTIntegrationConnector (REST/JSON over HTTP).
25
32
  *
26
- * Authenticates via a Bearer token (private token or OAuth2 access token, resolved
27
- * upstream and held in the credential store). Reads ride the base
28
- * {@link BaseRESTIntegrationConnector} pull path; this class overrides only the
29
- * genuinely Eventbrite-specific bits:
30
- * - {@link BuildHeaders}: `Authorization: Bearer <token>` + `Accept: application/json`.
31
- * - {@link GetBaseURL}: the shared `https://www.eventbriteapi.com/v3` host (or a
32
- * non-secret `ApiBaseUrl` override for mock testing).
33
- * - {@link NormalizeResponse}: unwraps the named-key list envelope (the IO's
34
- * `ResponseDataKey`, e.g. `events`/`attendees`/`orders`) which sits alongside a
35
- * `pagination` object; a bare object is a one-record detail; null → [].
36
- * - {@link ExtractPaginationInfo} / {@link BuildPaginatedURL}: Eventbrite continuation-cursor
37
- * pagination (`?continuation=<token>` + `pagination.has_more_items`/`.continuation`),
38
- * plus the `changed_since=<watermark>` param for incremental objects (Order/Attendee) —
39
- * fully metadata-driven (read `obj.IncrementalWatermarkField` + `obj.SupportsIncrementalSync`),
40
- * NEVER keyed off a hardcoded object name.
41
- * - {@link FetchChanges}: sets the watermark context, delegates to the base, then advances
42
- * the watermark from the records' `changed` field on the final batch only (HasMore=false)
43
- * so a partial-failure mid-pagination leaves the watermark unchanged.
44
- *
45
- * Create/Update/Delete use the generic per-operation column path
46
- * (CreateAPIPath/Method/BodyShape/BodyKey/IDLocation, Update*, Delete*) driven entirely
47
- * by the IO metadata — no per-verb override. Eventbrite uses POST for update (its
48
- * convention) and wrapped bodies for most resources (CreateBodyKey =
49
- * event|ticket_class|venue|discount|question|webhook|...). Parent template vars
50
- * ({organization_id}/{event_id}) are resolved by the engine's parent-iteration from each
51
- * IO's `Configuration.parentObjectName`.
33
+ * Discovery, template-var parent traversal, and the paginated GET loop are inherited. This class supplies
34
+ * only the Eventbrite-specific protocol surface: Bearer auth, the continuation-token cursor, the
35
+ * `changed_since` incremental pull, connection testing, the write path (vendor-named path vars), and the
36
+ * §7/§10 sync-efficiency hooks the frozen contract evidences.
52
37
  */
53
38
  let EventbriteConnector = class EventbriteConnector extends BaseRESTIntegrationConnector {
54
39
  constructor() {
55
40
  super(...arguments);
56
- /** Cached auth context (token + resolved host). */
57
- this.authState = null;
58
- /** Timestamp of the last outbound request, used for throttling. */
59
- this.lastRequestTime = 0;
60
- }
61
- // ── Identity + capability getters ───────────────────────────────────
62
- /** Verbatim from the metadata Integration row part of the three-way name invariant. */
63
- get IntegrationName() { return 'Eventbrite'; }
64
- // Eventbrite exposes a documented write API for a subset of objects (Events, Ticket
65
- // Classes, Venues, Discounts, Questions, Webhooks, Ticket Groups, Inventory Tiers, Event
66
- // Teams, Media). The generic per-operation path enforces null-capability honesty per IO
67
- // (an IO with no CreateAPIPath throws on create), so these getters reflect that SOME IOs
68
- // are writable; the actual writable set is each IO's per-op columns in the metadata.
41
+ /** Cached auth for the lifetime of a single sync run (Eventbrite tokens are long-lived, no refresh). */
42
+ this.cachedAuth = null;
43
+ /**
44
+ * The active incremental watermark for the object currently being fetched, stashed by the FetchChanges
45
+ * override so the (ctx-less) BuildPaginatedURL can append `changed_since`. Set at the top of an
46
+ * incremental FetchChanges, cleared in its finally. Safe because the engine drives one FetchChanges per
47
+ * object at a time (single-threaded async; no concurrent BuildPaginatedURL for a different watermark).
48
+ */
49
+ this.activeChangedSince = null;
50
+ }
51
+ // ── Identity (T1 three-way invariant) ────────────────────────────
52
+ /** Verbatim `MJ: Integrations.Name`. Load-bearing: the T1 three-way name check compares this === metadata Name. */
53
+ get IntegrationName() {
54
+ return 'eventbrite';
55
+ }
56
+ // ── Capability getters (kept in lockstep with the per-op metadata columns) ──
57
+ // Write surface is a MIXED subset (Configuration.WriteCapability): create on 15/33 objects, update on a
58
+ // subset, delete on a smaller subset. The connector-level getters report the UNION (the connector CAN do
59
+ // each verb); per-object null-column honesty is enforced by the overrides below (an object with no
60
+ // Create/Update/Delete path fails loudly rather than sending a broken URL).
69
61
  get SupportsCreate() { return true; }
70
62
  get SupportsUpdate() { return true; }
71
63
  get SupportsDelete() { return true; }
72
- // ── Sync-efficiency hooks ───────────────────────────────────────────
73
64
  /**
74
- * Eventbrite documents a default 2,000 calls/hour per token (HTTP 429 HIT_RATE_LIMIT
75
- * over it). Run under that ceiling; the engine's AIMD bucket throttles + backs off.
65
+ * Discovery is NON-authoritative: DiscoverObjects / IntrospectSchema are cache-driven (they re-read
66
+ * persisted ACTIVE Declared metadata, NOT a live full-gamut enumeration). Eventbrite publishes no
67
+ * schema/describe/introspection endpoint enumerating everything a credential can access, so absence in a
68
+ * refresh proves nothing → never deactivate. Matches Configuration.DiscoveryIsAuthoritative=false.
76
69
  */
77
- get RateLimitPolicy() {
78
- return { TokensPerSec: RATE_LIMIT_TOKENS_PER_SEC, Burst: RATE_LIMIT_BURST, ThrottleBackoffFactor: 0.5 };
70
+ get DiscoveryIsAuthoritative() {
71
+ return false;
79
72
  }
80
73
  /**
81
- * Cap how many EVENT/ORG parents a second-layer object (TicketClass, Attendee, Question,
82
- * EventTeam, InventoryTier, ) iterates per FetchChanges call. At Eventbrite's deliberately
83
- * conservative 0.27 tok/s (~3.7 s/request once the SHARED burst is spent by earlier objects in
84
- * the same sync), the base default of 10 parents costs ~37 s and blows the 30 s FetchChanges
85
- * op-timeout the batch is then abandoned and the object syncs 0 records for any org with that
86
- * many events. A batch of 4 costs ~15 s (well under 30 s) and the engine resumes the remaining
87
- * parents via HasMore/keyset, so a high-event-count org still syncs completely.
74
+ * IntrospectSchema pure WIRING of MJ's existing sampler into the declared catalog (the connector
75
+ * sample-union standard; see CONNECTOR_DISCOVERY_STANDARD.md). This connector adds NO discovery,
76
+ * merge, or sync logic it only wires `DiscoverFieldsViaFetch` (MJ's sampler) into IntrospectSchema.
77
+ *
78
+ * `super.IntrospectSchema` yields the cache-driven Declared catalog (no measured widths). For each
79
+ * object we then call MJ's `DiscoverFieldsViaFetch` MJ's own read-path sampler that measures real
80
+ * field widths and surfaces custom columns and the shared PURE `mergeDeclaredWithSampledFields`
81
+ * unions the two by field name (adopt MJ's measured width; append MJ-discovered custom columns). MJ
82
+ * owns everything else (measurement, type/PK inference, persistence, reconcile, sync).
83
+ *
84
+ * Recursion note: `DiscoverFieldsViaFetch` falls back to the UNCHANGED `DiscoverFields` (cache-driven)
85
+ * when the read path can't run — never back into THIS method — so there is no infinite recursion.
86
+ * This connector does NOT override `DiscoverFields` to call any ViaFetch/ViaStream.
87
+ *
88
+ * Robustness: objects are sampled IN PARALLEL under a small bounded pool; any per-object failure
89
+ * keeps that object's declared fields, so a single bad sample never breaks introspection.
88
90
  */
89
- TemplateVarParentBatchSize() {
90
- return 4;
91
- }
92
- /** Parse Eventbrite's Retry-After header (delta-seconds or HTTP-date) into milliseconds. */
93
- ExtractRetryAfterMs(error) {
94
- const headers = error?.Headers;
95
- if (!headers)
96
- return undefined;
97
- const retryAfter = headers['retry-after'] ?? headers['Retry-After'];
98
- if (typeof retryAfter !== 'string' || retryAfter.length === 0)
99
- return undefined;
100
- const asSeconds = Number(retryAfter);
101
- if (!isNaN(asSeconds) && asSeconds >= 0)
102
- return Math.round(asSeconds * 1000);
103
- const asDate = Date.parse(retryAfter);
104
- if (!isNaN(asDate)) {
105
- const delta = asDate - Date.now();
106
- if (delta > 0)
107
- return delta;
108
- }
109
- return undefined;
91
+ async IntrospectSchema(companyIntegration, contextUser) {
92
+ const schema = await super.IntrospectSchema(companyIntegration, contextUser);
93
+ await runBounded(schema.Objects, 8, async (obj) => {
94
+ try {
95
+ const sampled = await this.DiscoverFieldsViaFetch(companyIntegration, obj.ExternalName, contextUser);
96
+ obj.Fields = mergeDeclaredWithSampledFields(obj.Fields, sampled);
97
+ }
98
+ catch {
99
+ // Keep this object's declared fields — sampling is best-effort and never breaks introspection.
100
+ }
101
+ });
102
+ return schema;
110
103
  }
111
- // ─── TestConnection ──────────────────────────────────────────────
104
+ // ── Sync-efficiency hooks (§7/§10 — populated from frozen-contract Configuration facts) ──
112
105
  /**
113
- * Verifies connectivity via `GET /users/me/`. A 2xx confirms the Bearer token is valid;
114
- * a 401/403 means the token was rejected (NOT_AUTH / NOT_PERMITTED).
106
+ * From Configuration.RateLimitPolicy: 2,000 calls/hour per token (blueprint ## Errors, 429
107
+ * HIT_RATE_LIMIT). 2000/3600 0.556 tokens/sec sustained. Burst kept small (the hourly ceiling is the
108
+ * real constraint; there is no documented per-second burst allowance).
115
109
  */
116
- async TestConnection(companyIntegration, contextUser) {
117
- try {
118
- const auth = await this.Authenticate(companyIntegration, contextUser);
119
- const headers = this.BuildHeaders(auth);
120
- const probeUrl = `${auth.BaseUrl}/users/me/`;
121
- const resp = await this.MakeHTTPRequest(auth, probeUrl, 'GET', headers);
122
- if (resp.Status === 401 || resp.Status === 403) {
123
- return { Success: false, Message: `Eventbrite TestConnection failed: HTTP ${resp.Status} (token rejected)` };
124
- }
125
- if (resp.Status >= 500) {
126
- return { Success: false, Message: `Eventbrite TestConnection failed: HTTP ${resp.Status} (server error)` };
127
- }
128
- if (resp.Status < 200 || resp.Status >= 300) {
129
- return { Success: false, Message: `Eventbrite returned HTTP ${resp.Status} from ${probeUrl}` };
130
- }
131
- return {
132
- Success: true,
133
- Message: `Connected to Eventbrite Platform API v3 at ${auth.BaseUrl}`,
134
- ServerVersion: 'Eventbrite API v3',
135
- };
136
- }
137
- catch (err) {
138
- const message = err instanceof Error ? err.message : String(err);
139
- return { Success: false, Message: `Connection failed: ${message}` };
140
- }
110
+ get RateLimitPolicy() {
111
+ return { TokensPerSec: 0.5556, Burst: 5 };
141
112
  }
142
- // ─── FetchChanges (watermark-aware) ───────────────────────────────
143
113
  /**
144
- * Sets the watermark context that {@link BuildPaginatedURL} emits as the IO's
145
- * incremental `changed_since` param (for Order/Attendee), delegates the actual fetch +
146
- * cursor pagination to the base, then advances the watermark from the returned records'
147
- * `changed` field on the FINAL batch only (HasMore=false) so a partial-failure
148
- * mid-pagination leaves the watermark unchanged.
114
+ * The frozen contract records NO Retry-After header shape for Eventbrite's 429 (HIT_RATE_LIMIT)
115
+ * (Configuration.RateLimitPolicy.retryAfterHeaderDocumented=false), so there is nothing to parse
116
+ * reliably. Left as the base default (undefined) rather than guessing a header name — the engine's AIMD
117
+ * bucket backs off on the 429 regardless. Documented as a soft gap for live-probe confirmation.
149
118
  */
150
- async FetchChanges(ctx) {
151
- this.currentWatermark = ctx.WatermarkValue ?? undefined;
152
- const result = await super.FetchChanges(ctx);
153
- const obj = this.GetCachedObject(ctx.CompanyIntegration.IntegrationID, ctx.ObjectName);
154
- if (obj.SupportsIncrementalSync && obj.IncrementalWatermarkField && !result.HasMore) {
155
- const advanced = this.ExtractLatestWatermark(result.Records, obj.IncrementalWatermarkField);
156
- const newWatermark = advanced ?? ctx.WatermarkValue ?? undefined;
157
- if (newWatermark != null)
158
- return { ...result, NewWatermarkValue: newWatermark };
159
- }
160
- return result;
119
+ // ExtractRetryAfterMs: inherited default (undefined). See note above.
120
+ /** Conservative in-flight cap. The 2,000/hour ceiling is the real limiter; a low cap avoids bursts. */
121
+ get MaxConcurrencyHint() {
122
+ return 2;
161
123
  }
162
124
  /**
163
- * Scans a batch for the latest watermark value. Eventbrite's incremental objects
164
- * (Order, Attendee) carry a `changed` ISO-8601 timestamp per record (the field named by
165
- * the IO's `IncrementalWatermarkField`); we take the max so the next run's `changed_since`
166
- * resumes from there.
125
+ * No-watermark objects resume by their StableOrderingKey — read from the IO metadata when the extractor
126
+ * emitted one, else the object's PK (Eventbrite's universal `id`). Returns null when the object has no
127
+ * stable key or the cache is unavailable (unit-test context).
167
128
  */
168
- ExtractLatestWatermark(records, watermarkField) {
169
- let latest = null;
170
- for (const rec of records) {
171
- const raw = rec.Fields?.[watermarkField];
172
- if (typeof raw !== 'string' || raw.length === 0)
173
- continue;
174
- const d = new Date(raw);
175
- if (!isNaN(d.getTime()) && (latest === null || d > latest))
176
- latest = d;
177
- }
178
- return latest ? latest.toISOString() : null;
129
+ StableOrderingKey(objectName) {
130
+ const obj = this.TryGetActiveObject(objectName);
131
+ if (!obj)
132
+ return null;
133
+ const declared = obj.StableOrderingKey;
134
+ if (declared && declared.trim().length > 0)
135
+ return declared.trim();
136
+ const pk = this.GetCachedFields(obj.ID).find(f => f.IsPrimaryKey);
137
+ return pk?.Name ?? null;
179
138
  }
180
- // ─── Auth + transport (abstract base requirements) ────────────────
139
+ // ── Abstract REST hooks ──────────────────────────────────────────
140
+ /**
141
+ * Resolves the pre-minted Bearer token from the linked Credential entity (preferred) or the
142
+ * CompanyIntegration Configuration JSON (fallback). Cached for the run.
143
+ */
181
144
  async Authenticate(companyIntegration, contextUser) {
182
- if (this.authState)
183
- return this.authState;
184
- const config = await this.parseConfig(companyIntegration, contextUser);
185
- const state = {
186
- Token: config.Token ?? '',
187
- BaseUrl: this.resolveBaseUrl(config),
188
- Config: config,
189
- };
190
- this.authState = state;
191
- return state;
145
+ if (this.cachedAuth)
146
+ return this.cachedAuth;
147
+ const creds = await this.LoadCredentials(companyIntegration, contextUser);
148
+ this.cachedAuth = { Token: creds.AccessToken };
149
+ return this.cachedAuth;
192
150
  }
193
- /** Builds the Eventbrite auth header: `Authorization: Bearer <token>` + `Accept: application/json`. */
151
+ /** Eventbrite OAuth2 auth: a pre-minted Bearer token. No signing, no crypto. */
194
152
  BuildHeaders(auth) {
195
- const ebAuth = auth;
153
+ const token = auth.Token;
196
154
  return {
197
- 'Authorization': `Bearer ${ebAuth.Token}`,
198
- 'Accept': 'application/json',
155
+ 'Authorization': `Bearer ${token}`,
199
156
  'Content-Type': 'application/json',
157
+ 'Accept': 'application/json',
200
158
  };
201
159
  }
160
+ /** HTTP transport (fetch). Owns the wire boundary; test subclasses override this to capture requests. */
161
+ async MakeHTTPRequest(_auth, url, method, headers, body) {
162
+ const response = await fetch(url, {
163
+ method,
164
+ headers,
165
+ body: body !== undefined ? JSON.stringify(body) : undefined,
166
+ });
167
+ const respHeaders = {};
168
+ response.headers.forEach((v, k) => { respHeaders[k.toLowerCase()] = v; });
169
+ const text = await response.text();
170
+ let parsed = null;
171
+ if (text.length > 0) {
172
+ try {
173
+ parsed = JSON.parse(text);
174
+ }
175
+ catch {
176
+ parsed = text;
177
+ }
178
+ }
179
+ return { Status: response.status, Body: parsed, Headers: respHeaders };
180
+ }
202
181
  /**
203
- * Unwraps Eventbrite's response shapes:
204
- * - List endpoints: `{ <responseDataKey>: [ ... ], pagination: { ... } }` → the named array.
205
- * - Single-record detail (no responseDataKey): a bare object a one-element array.
206
- * - null [].
207
- * The IO metadata's `responseDataKey` (e.g. `events`/`attendees`/`orders`) is the
208
- * authoritative envelope key; when absent the body is treated as a bare detail object.
182
+ * Strips the Eventbrite list envelope. Each list endpoint nests records under a plural snake_case
183
+ * resource key (`events`, `attendees`, `orders`, …), stored per-IO as ResponseDataKey. When
184
+ * ResponseDataKey is unset (the extractor left it null), the key is DERIVED from the last non-templated
185
+ * path segment of the APIPath — but NormalizeResponse doesn't have the IO here, so the fallback scans the
186
+ * envelope for the sole array-valued key alongside `pagination`. A bare-array or single-object body (the
187
+ * get-one / non-paginated shape) is handled directly.
209
188
  */
210
189
  NormalizeResponse(rawBody, responseDataKey) {
211
190
  if (rawBody == null)
212
191
  return [];
213
- const asObj = this.asObject(rawBody);
214
- // Explicit metadata-declared envelope key wins (the list case).
215
- if (responseDataKey && asObj) {
216
- if (responseDataKey in asObj)
217
- return this.coerceToArray(asObj[responseDataKey]);
218
- // Key declared but absent (e.g. an error or empty envelope) — nothing to emit.
192
+ if (Array.isArray(rawBody))
193
+ return rawBody;
194
+ if (typeof rawBody !== 'object')
219
195
  return [];
196
+ const body = rawBody;
197
+ // Preferred: the metadata-declared data key.
198
+ if (responseDataKey) {
199
+ const arr = body[responseDataKey];
200
+ if (Array.isArray(arr))
201
+ return arr;
220
202
  }
221
- if (asObj) {
222
- // Bare single-record detail object (no responseDataKey, e.g. User / Media / Organizer).
223
- return [asObj];
203
+ // Fallback: an Eventbrite list body has exactly one array-valued key besides `pagination`.
204
+ if (body.pagination !== undefined) {
205
+ for (const [key, val] of Object.entries(body)) {
206
+ if (key === 'pagination')
207
+ continue;
208
+ if (Array.isArray(val))
209
+ return val;
210
+ }
211
+ return [];
224
212
  }
225
- // Bare array fallback (defensive Eventbrite list shapes are always keyed).
226
- if (Array.isArray(rawBody))
227
- return rawBody.filter(this.isRecord);
228
- return [];
229
- }
230
- coerceToArray(node) {
231
- if (Array.isArray(node))
232
- return node.filter(this.isRecord);
233
- if (node && typeof node === 'object')
234
- return [node];
235
- return [];
236
- }
237
- isRecord(node) {
238
- return node != null && typeof node === 'object' && !Array.isArray(node);
213
+ // get-one / non-list shape: the body IS the record.
214
+ return [body];
239
215
  }
240
216
  /**
241
- * Continuation-cursor pagination: Eventbrite returns a `pagination` envelope carrying
242
- * `has_more_items` (boolean) and `continuation` (opaque cursor token). There are more
243
- * records while `has_more_items` is true AND a continuation token is present. The
244
- * total record count (`object_count`) is surfaced when available.
245
- *
246
- * The `Cursor` pagination type drives this; non-cursor objects (`None`) report no more.
217
+ * Eventbrite pagination is the CONTINUATION-TOKEN scheme (NOT page-number/offset). Read
218
+ * `pagination.has_more_items` and `pagination.continuation` from the envelope. When has_more_items is
219
+ * true AND a continuation token is present, the next page is requested with that token; otherwise the set
220
+ * is exhausted. Source: Configuration.PaginationDefaults.advanceProtocol.
221
+ * currentPage/offset/pageSize are unused for continuation pagination.
247
222
  */
248
- ExtractPaginationInfo(rawBody, paginationType, currentPage, currentOffset, _pageSize) {
249
- if (paginationType !== 'Cursor') {
250
- return { HasMore: false, NextPage: currentPage, NextOffset: currentOffset };
251
- }
252
- const asObj = this.asObject(rawBody);
253
- const pagination = asObj ? this.asObject(asObj['pagination']) : undefined;
254
- const hasMore = pagination?.has_more_items === true
255
- && typeof pagination?.continuation === 'string'
256
- && pagination.continuation.length > 0;
257
- const totalRecords = typeof pagination?.object_count === 'number' ? pagination.object_count : undefined;
258
- return {
259
- HasMore: hasMore,
260
- NextPage: currentPage + 1,
261
- NextOffset: currentOffset,
262
- NextCursor: hasMore ? pagination?.continuation : undefined,
263
- TotalRecords: totalRecords,
264
- };
265
- }
266
- /**
267
- * Builds the paginated request URL. Eventbrite cursor pagination uses `?continuation=<token>`
268
- * (the base default emits `?cursor=...&limit=...`), so we override to emit the vendor's
269
- * param. Also emits the IO's `IncrementalWatermarkField` as `changed_since=<watermark>`
270
- * when this is an incremental object (Order/Attendee) and a watermark is in context —
271
- * fully metadata-driven (read `obj.IncrementalWatermarkField` + `obj.SupportsIncrementalSync`),
272
- * NEVER keyed off a hardcoded object name.
273
- *
274
- * Parent template vars ({organization_id}/{event_id}) are NOT substituted here — those are
275
- * resolved by the engine's parent-iteration from the IO's `Configuration.parentObjectName`.
276
- */
277
- BuildPaginatedURL(basePath, obj, _page, _offset, cursor, _effectivePageSize) {
278
- const separator = basePath.includes('?') ? '&' : '?';
279
- const params = new URLSearchParams();
280
- // Incremental param (changed_since) for objects that declare a watermark field.
281
- const watermarkParam = this.resolveWatermarkParam(obj);
282
- if (obj.SupportsIncrementalSync && watermarkParam && this.currentWatermark) {
283
- params.set(watermarkParam, this.currentWatermark);
284
- }
285
- // Continuation cursor for the Cursor pagination type (after the first page).
286
- if (obj.PaginationType === 'Cursor' && cursor) {
287
- params.set('continuation', cursor);
223
+ ExtractPaginationInfo(rawBody, _paginationType, _currentPage, _currentOffset, _pageSize) {
224
+ if (rawBody && typeof rawBody === 'object') {
225
+ const env = rawBody;
226
+ const pag = env.pagination;
227
+ if (pag && pag.has_more_items === true && typeof pag.continuation === 'string' && pag.continuation.length > 0) {
228
+ return { HasMore: true, NextCursor: pag.continuation, TotalRecords: pag.object_count };
229
+ }
230
+ return { HasMore: false, TotalRecords: pag?.object_count };
288
231
  }
289
- const qs = params.toString();
290
- return qs.length > 0 ? `${basePath}${separator}${qs}` : basePath;
232
+ return { HasMore: false };
291
233
  }
292
234
  /**
293
- * Resolves the vendor-side query param name for an incremental object. Eventbrite's
294
- * incremental filter param is `changed_since` (the IO's `IncrementalWatermarkField` is the
295
- * RECORD field `changed`). Metadata-driven: only objects whose `SupportsIncrementalSync` is
296
- * true and whose watermark field is set receive the param.
235
+ * Eventbrite v3 host. Defaults to the fixed `https://www.eventbriteapi.com/v3` host (the version
236
+ * segment is part of the base URL; metadata APIPaths are relative). Honors a
237
+ * `CompanyIntegration.Configuration.BaseURL` override when present used for region redirects and
238
+ * for pointing the connector at a mock ORIGIN server in credential-free e2e testing without touching
239
+ * the vendor's real endpoint. Falls back to the fixed host on absence or malformed Configuration.
297
240
  */
298
- resolveWatermarkParam(obj) {
299
- if (!obj.SupportsIncrementalSync || !obj.IncrementalWatermarkField)
300
- return undefined;
301
- return 'changed_since';
302
- }
303
- GetBaseURL(_companyIntegration, auth) {
304
- return auth.BaseUrl;
305
- }
306
- // ─── HTTP transport with retry + throttling ───────────────────────
307
- async MakeHTTPRequest(auth, url, method, headers, body) {
308
- const ebAuth = auth;
309
- const cfg = ebAuth.Config;
310
- const maxRetries = cfg.MaxRetries ?? DEFAULT_MAX_RETRIES;
311
- const timeoutMs = cfg.RequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
312
- const minInterval = cfg.MinRequestIntervalMs ?? DEFAULT_MIN_REQUEST_INTERVAL_MS;
313
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
314
- await this.throttle(minInterval);
241
+ GetBaseURL(companyIntegration) {
242
+ const cfgRaw = companyIntegration?.Configuration;
243
+ if (cfgRaw) {
315
244
  try {
316
- const resp = await this.doFetch(url, method, headers, body, timeoutMs);
317
- this.lastRequestTime = Date.now();
318
- if ((resp.Status === 429 || resp.Status === 503) && attempt < maxRetries) {
319
- await this.sleep(this.backoffFromResponse(resp, attempt));
320
- continue;
245
+ const cfg = (typeof cfgRaw === 'string' ? JSON.parse(cfgRaw) : cfgRaw);
246
+ const override = cfg?.BaseURL ?? cfg?.baseUrl ?? cfg?.apiBaseUrl;
247
+ if (typeof override === 'string' && override.trim().length > 0) {
248
+ return override.replace(/\/+$/, '');
321
249
  }
322
- return resp;
323
250
  }
324
- catch (err) {
325
- if (attempt === maxRetries)
326
- throw err;
327
- if (!this.isRetryableError(err))
328
- throw err;
329
- await this.sleep(this.backoffMs(attempt));
251
+ catch {
252
+ /* malformed Configuration JSON → fall back to the fixed host */
330
253
  }
331
254
  }
332
- throw new Error(`Eventbrite request to ${url} exhausted ${maxRetries + 1} attempts`);
255
+ return EVENTBRITE_API_BASE;
333
256
  }
334
- /** Single fetch() with an AbortController-backed timeout. */
335
- async doFetch(url, method, headers, body, timeoutMs) {
336
- const controller = new AbortController();
337
- const handle = setTimeout(() => controller.abort(), timeoutMs);
257
+ /**
258
+ * Eventbrite pages via the `continuation` query param (NOT the base default `cursor=`). The first page
259
+ * sends no continuation token; subsequent pages send `continuation=<token>`. When an incremental
260
+ * watermark is active (Attendee/Order this run), `changed_since=<watermark>` is appended so the API
261
+ * returns only records changed after the watermark. Eventbrite has no client-controlled page-size param
262
+ * on these list endpoints (page_size is server-fixed and reported in the envelope), so no limit is sent.
263
+ */
264
+ BuildPaginatedURL(basePath, _obj, _page, _offset, cursor, _effectivePageSize) {
265
+ const parts = [];
266
+ if (cursor)
267
+ parts.push(`${CONTINUATION_PARAM}=${encodeURIComponent(cursor)}`);
268
+ if (this.activeChangedSince)
269
+ parts.push(`${CHANGED_SINCE_PARAM}=${encodeURIComponent(this.activeChangedSince)}`);
270
+ if (parts.length === 0)
271
+ return basePath;
272
+ const separator = basePath.includes('?') ? '&' : '?';
273
+ return `${basePath}${separator}${parts.join('&')}`;
274
+ }
275
+ // ── FetchChanges override (incremental changed_since + new-watermark emission) ──
276
+ /**
277
+ * OVERRIDDEN to (1) inject the `changed_since` param for the two incremental objects (Attendee, Order —
278
+ * SupportsIncrementalSync + a watermark this run) and (2) EMIT the new watermark. The base flat/template
279
+ * fetch path threads no watermark into the URL and returns no NewWatermarkValue, so the connector owns
280
+ * both. All fetching (continuation pagination, template-var parent traversal) is delegated to the base;
281
+ * this override only sets activeChangedSince around the super call and computes the watermark on a fully
282
+ * drained batch.
283
+ *
284
+ * Partial-failure safety: NewWatermarkValue is emitted ONLY when the whole object is drained
285
+ * (HasMore=false). A mid-stream batch (HasMore=true — more parents to iterate) advances no watermark, so a
286
+ * failure between batches resumes from the unchanged prior watermark. When the final batch's max `changed`
287
+ * is below an earlier batch's, the watermark under-advances (worst case re-fetches already-seen records
288
+ * next run — idempotent, safe) rather than skipping records (data loss).
289
+ */
290
+ async FetchChanges(ctx) {
291
+ const obj = this.GetCachedObject(ctx.CompanyIntegration.IntegrationID, ctx.ObjectName);
292
+ const incremental = obj.SupportsIncrementalSync && ctx.WatermarkValue != null && ctx.WatermarkValue.length > 0;
293
+ if (!incremental) {
294
+ return super.FetchChanges(ctx);
295
+ }
296
+ this.activeChangedSince = this.FormatChangedSince(ctx.WatermarkValue);
338
297
  try {
339
- const resp = await fetch(url, {
340
- method,
341
- headers,
342
- body: body !== undefined && method !== 'GET' ? JSON.stringify(body) : undefined,
343
- signal: controller.signal,
344
- });
345
- const respHeaders = {};
346
- resp.headers.forEach((value, key) => { respHeaders[key.toLowerCase()] = value; });
347
- const text = await resp.text();
348
- const parsed = text.length > 0 ? this.safeParseJSON(text) : null;
349
- return { Status: resp.status, Body: parsed, Headers: respHeaders };
298
+ const result = await super.FetchChanges(ctx);
299
+ if (!result.HasMore) {
300
+ const wmField = obj.IncrementalWatermarkField ?? 'changed';
301
+ result.NewWatermarkValue = this.MaxWatermark(result, wmField, ctx.WatermarkValue);
302
+ }
303
+ return result;
350
304
  }
351
305
  finally {
352
- clearTimeout(handle);
306
+ this.activeChangedSince = null;
353
307
  }
354
308
  }
355
- safeParseJSON(text) {
356
- try {
357
- return JSON.parse(text);
309
+ // ── CRUD ──────────────────────────────────────────────────────────
310
+ //
311
+ // OVERRIDDEN (all three verbs) because Eventbrite's write paths carry VENDOR-NAMED template vars the
312
+ // base's single-{id} SubstituteIDInPath cannot build: create paths carry a parent var
313
+ // (`{organization_id}`/`{event_id}`); update/delete paths carry the record's own id under a vendor name
314
+ // (`{event_id}`, `{venue_id}`, `{discount_id}`, …) and several also carry a parent var
315
+ // (`/events/{event_id}/ticket_classes/{ticket_class_id}/`). Body shaping, ID extraction, and error
316
+ // handling all reuse the base helpers; create STILL routes through BuildCreatedResult (loud-on-empty-id).
317
+ /**
318
+ * Create: substitutes the create path's parent vars from Attributes/Relationships (create paths carry
319
+ * only parent vars — the record has no id yet), POSTs the body shaped per CreateBodyShape/CreateBodyKey,
320
+ * and routes the result through BuildCreatedResult so a 2xx with no usable id FAILS LOUDLY.
321
+ */
322
+ async CreateRecord(ctx) {
323
+ const ci = ctx.CompanyIntegration;
324
+ const contextUser = ctx.ContextUser;
325
+ const obj = this.GetCachedObject(ci.IntegrationID, ctx.ObjectName);
326
+ if (!obj.CreateAPIPath || !obj.CreateMethod) {
327
+ return { Success: false, StatusCode: 0, ErrorMessage: `[eventbrite] CreateRecord not supported for "${ctx.ObjectName}": CreateAPIPath / CreateMethod not configured.` };
358
328
  }
359
- catch {
360
- return text;
329
+ const resolved = this.SubstituteAllPathVars(obj.CreateAPIPath, undefined, ctx.Attributes, ctx.Relationships);
330
+ if (resolved.unresolved.length > 0) {
331
+ return this.UnresolvedVarError(ctx.ObjectName, 'create', obj.CreateAPIPath, resolved.unresolved);
361
332
  }
333
+ const auth = await this.Authenticate(ci, contextUser);
334
+ const headers = this.BuildHeaders(auth);
335
+ const url = `${this.GetBaseURL(ci)}${resolved.path}`;
336
+ const body = this.BuildOperationBody(ctx.Attributes, obj.CreateBodyShape, obj.CreateBodyKey);
337
+ const response = await this.MakeHTTPRequest(auth, url, obj.CreateMethod, headers, body);
338
+ if (response.Status >= 200 && response.Status < 300) {
339
+ const externalID = this.ExtractIDFromResponse(response, obj.CreateIDLocation);
340
+ return this.BuildCreatedResult(externalID, response.Status, ctx.ObjectName);
341
+ }
342
+ return { Success: false, StatusCode: response.Status,
343
+ ErrorMessage: this.ExtractErrorMessage(response) ?? `HTTP ${response.Status} on create` };
362
344
  }
363
- isRetryableError(err) {
364
- const msg = err instanceof Error ? err.message : String(err);
365
- return /abort|timeout|ECONNRESET|ENOTFOUND|ETIMEDOUT|ECONNREFUSED|fetch failed|network/i.test(msg);
366
- }
367
- backoffMs(attempt) {
368
- const base = Math.min(1000 * Math.pow(2, attempt), 20000);
369
- const jitter = Math.floor(Math.random() * 500);
370
- return base + jitter;
371
- }
372
- backoffFromResponse(resp, attempt) {
373
- const fromHeader = this.ExtractRetryAfterMs({ Headers: resp.Headers });
374
- if (fromHeader != null)
375
- return Math.min(fromHeader, 30000);
376
- return this.backoffMs(attempt);
377
- }
378
- async throttle(minIntervalMs) {
379
- const elapsed = Date.now() - this.lastRequestTime;
380
- if (elapsed < minIntervalMs)
381
- await this.sleep(minIntervalMs - elapsed);
382
- }
383
- sleep(ms) {
384
- return new Promise(resolve => setTimeout(resolve, ms));
385
- }
386
- asObject(node) {
387
- return node && typeof node === 'object' && !Array.isArray(node) ? node : undefined;
345
+ /**
346
+ * Update: substitutes the record's own id (ExternalID) into the LAST path var and any parent vars from
347
+ * Attributes/Relationships, then POSTs the wrapped body (Eventbrite uses POST, not PATCH/PUT, for update).
348
+ */
349
+ async UpdateRecord(ctx) {
350
+ const ci = ctx.CompanyIntegration;
351
+ const contextUser = ctx.ContextUser;
352
+ const obj = this.GetCachedObject(ci.IntegrationID, ctx.ObjectName);
353
+ if (!obj.UpdateAPIPath || !obj.UpdateMethod) {
354
+ return { Success: false, StatusCode: 0, ErrorMessage: `[eventbrite] UpdateRecord not supported for "${ctx.ObjectName}": UpdateAPIPath / UpdateMethod not configured.` };
355
+ }
356
+ const resolved = this.SubstituteAllPathVars(obj.UpdateAPIPath, ctx.ExternalID, ctx.Attributes, ctx.Relationships);
357
+ if (resolved.unresolved.length > 0) {
358
+ return this.UnresolvedVarError(ctx.ObjectName, 'update', obj.UpdateAPIPath, resolved.unresolved);
359
+ }
360
+ const auth = await this.Authenticate(ci, contextUser);
361
+ const headers = this.BuildHeaders(auth);
362
+ const url = `${this.GetBaseURL(ci)}${resolved.path}`;
363
+ const body = this.BuildOperationBody(ctx.Attributes, obj.UpdateBodyShape, obj.UpdateBodyKey);
364
+ const response = await this.MakeHTTPRequest(auth, url, obj.UpdateMethod, headers, body);
365
+ if (response.Status >= 200 && response.Status < 300) {
366
+ return { Success: true, StatusCode: response.Status, ExternalID: ctx.ExternalID };
367
+ }
368
+ return { Success: false, StatusCode: response.Status,
369
+ ErrorMessage: this.ExtractErrorMessage(response) ?? `HTTP ${response.Status} on update` };
388
370
  }
389
- // ─── Config parsing ───────────────────────────────────────────────
390
371
  /**
391
- * Resolves the connection config: the Bearer token from the credential store; the
392
- * non-secret host override + transport tunables from CompanyIntegration.Configuration.
393
- * Secrets are NEVER baked into code.
372
+ * Delete: substitutes the record's own id (ExternalID) into the last path var and any parent vars, then
373
+ * issues DeleteMethod (metadata-driven Eventbrite uses hard DELETE, but the verb is read from metadata,
374
+ * not assumed).
394
375
  */
395
- async parseConfig(companyIntegration, contextUser) {
396
- const fromCredential = companyIntegration.CredentialID
397
- ? await this.loadFromCredential(companyIntegration.CredentialID, contextUser)
398
- : null;
399
- const fromConfig = this.parseConfigurationJson(companyIntegration.Configuration);
400
- const merged = { ...fromConfig, ...fromCredential };
401
- // Credential's Token wins; host override + tunables come from Configuration.
402
- merged.Token = (fromCredential?.Token ?? fromConfig.Token);
403
- merged.ApiBaseUrl = merged.ApiBaseUrl ?? fromConfig.ApiBaseUrl;
404
- merged.RequestTimeoutMs = merged.RequestTimeoutMs ?? fromConfig.RequestTimeoutMs;
405
- merged.MaxRetries = merged.MaxRetries ?? fromConfig.MaxRetries;
406
- merged.MinRequestIntervalMs = merged.MinRequestIntervalMs ?? fromConfig.MinRequestIntervalMs;
407
- if (!merged.Token) {
408
- throw new Error('EventbriteConnector: a Bearer token (PrivateToken / Token / AccessToken / apiKey) ' +
409
- 'must be provided via the credential store or CompanyIntegration.Configuration.');
410
- }
411
- return merged;
412
- }
413
- /** Resolves the API host: the non-secret override wins over the production host. */
414
- resolveBaseUrl(config) {
415
- const raw = (config.ApiBaseUrl ?? PROD_API_HOST).trim();
416
- return raw.replace(/\/+$/, '');
417
- }
418
- /** Parses the non-secret host/tunables config from CompanyIntegration.Configuration JSON. */
419
- parseConfigurationJson(raw) {
420
- if (!raw || raw.trim().length === 0)
421
- return {};
422
- let parsed;
423
- try {
424
- parsed = JSON.parse(raw);
376
+ async DeleteRecord(ctx) {
377
+ const ci = ctx.CompanyIntegration;
378
+ const contextUser = ctx.ContextUser;
379
+ const obj = this.GetCachedObject(ci.IntegrationID, ctx.ObjectName);
380
+ if (!obj.DeleteAPIPath || !obj.DeleteMethod) {
381
+ return { Success: false, StatusCode: 0, ErrorMessage: `[eventbrite] DeleteRecord not supported for "${ctx.ObjectName}": DeleteAPIPath / DeleteMethod not configured.` };
425
382
  }
426
- catch {
427
- throw new Error('EventbriteConnector: CompanyIntegration.Configuration is not valid JSON.');
383
+ // Delete carries no Attributes; parent vars (if any) must ride the composite ExternalID as
384
+ // "parentId|recordId" (mirrors the base composite-PK ExternalID form) or be absent (single-var path).
385
+ const { parentTags, recordID } = this.SplitCompositeExternalID(ctx.ExternalID, obj.DeleteAPIPath);
386
+ const resolved = this.SubstituteAllPathVars(obj.DeleteAPIPath, recordID, parentTags, undefined);
387
+ if (resolved.unresolved.length > 0) {
388
+ return this.UnresolvedVarError(ctx.ObjectName, 'delete', obj.DeleteAPIPath, resolved.unresolved);
389
+ }
390
+ const auth = await this.Authenticate(ci, contextUser);
391
+ const headers = this.BuildHeaders(auth);
392
+ const url = `${this.GetBaseURL(ci)}${resolved.path}`;
393
+ const response = await this.MakeHTTPRequest(auth, url, obj.DeleteMethod, headers);
394
+ if (response.Status >= 200 && response.Status < 300) {
395
+ return { Success: true, StatusCode: response.Status, ExternalID: ctx.ExternalID };
428
396
  }
429
- const str = (...keys) => {
430
- for (const k of keys) {
431
- const hit = Object.entries(parsed).find(([key]) => key.toLowerCase() === k.toLowerCase());
432
- if (hit && typeof hit[1] === 'string' && hit[1].length > 0)
433
- return hit[1];
397
+ return { Success: false, StatusCode: response.Status,
398
+ ErrorMessage: this.ExtractErrorMessage(response) ?? `HTTP ${response.Status} on delete` };
399
+ }
400
+ // ── Connection test ──────────────────────────────────────────────
401
+ /**
402
+ * Tests the connection by hitting the current-user endpoint (`/users/me/`). A 2xx confirms the Bearer
403
+ * token is valid; 401/403 → auth failure; anything else → error.
404
+ */
405
+ async TestConnection(companyIntegration, contextUser) {
406
+ try {
407
+ const auth = await this.Authenticate(companyIntegration, contextUser);
408
+ const headers = this.BuildHeaders(auth);
409
+ const url = `${EVENTBRITE_API_BASE}/users/me/`;
410
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
411
+ if (response.Status >= 200 && response.Status < 300) {
412
+ return { Success: true, Message: 'Eventbrite connection successful.' };
434
413
  }
435
- return undefined;
436
- };
437
- const num = (...keys) => {
438
- for (const k of keys) {
439
- const hit = Object.entries(parsed).find(([key]) => key.toLowerCase() === k.toLowerCase());
440
- if (hit && typeof hit[1] === 'number')
441
- return hit[1];
414
+ if (response.Status === 401 || response.Status === 403) {
415
+ return { Success: false, Message: `Eventbrite authentication failed (HTTP ${response.Status}). Check the OAuth2 Bearer / Private Token.` };
442
416
  }
443
- return undefined;
444
- };
445
- return {
446
- // A token MAY be carried in Configuration for credential-free replay harnesses;
447
- // the credential store remains preferred (parseConfig gives fromCredential precedence).
448
- Token: str('PrivateToken', 'Token', 'AccessToken', 'accessToken', 'apiKey', 'api_key'),
449
- ApiBaseUrl: str('apiBaseUrl', 'api_base_url', 'APIBaseURL', 'baseUrl', 'base_url'),
450
- RequestTimeoutMs: num('requestTimeoutMs'),
451
- MaxRetries: num('maxRetries'),
452
- MinRequestIntervalMs: num('minRequestIntervalMs'),
453
- };
417
+ return { Success: false, Message: `Eventbrite connection test returned HTTP ${response.Status}.` };
418
+ }
419
+ catch (err) {
420
+ const msg = err instanceof Error ? err.message : String(err);
421
+ return { Success: false, Message: `Eventbrite connection test error: ${msg}` };
422
+ }
423
+ }
424
+ // ── Credential loading ───────────────────────────────────────────
425
+ /** Reads the Bearer token from the linked Credential entity, or the Configuration JSON fallback. */
426
+ async LoadCredentials(companyIntegration, contextUser) {
427
+ const credentialID = companyIntegration.CredentialID;
428
+ if (credentialID) {
429
+ const creds = await this.LoadFromCredentialEntity(credentialID, contextUser);
430
+ if (creds)
431
+ return creds;
432
+ }
433
+ const configJson = companyIntegration.Configuration;
434
+ if (configJson) {
435
+ const creds = this.ParseCredentialJson(configJson);
436
+ if (creds)
437
+ return creds;
438
+ }
439
+ throw new Error('No Eventbrite credential found. Attach a credential carrying an OAuth2 Bearer / Private Token ' +
440
+ '(accessToken / apiKey / Token), or set Configuration JSON on the CompanyIntegration.');
454
441
  }
455
- /** Loads the Bearer token from the MJ credential store. */
456
- async loadFromCredential(credentialID, contextUser, provider) {
457
- const md = provider ?? new Metadata();
442
+ /** Loads a credential row and parses its Values JSON. */
443
+ async LoadFromCredentialEntity(credentialID, contextUser) {
444
+ const md = new Metadata();
458
445
  const credential = await md.GetEntityObject('MJ: Credentials', contextUser);
459
446
  const loaded = await credential.Load(credentialID);
460
447
  if (!loaded || !credential.Values)
461
448
  return null;
462
- let raw;
449
+ return this.ParseCredentialJson(credential.Values);
450
+ }
451
+ /** Extracts an Eventbrite token from a credential/config JSON string. Returns null when no token is present. */
452
+ ParseCredentialJson(json) {
463
453
  try {
464
- raw = JSON.parse(credential.Values);
454
+ const parsed = JSON.parse(json);
455
+ const token = this.FirstString(parsed, ['accessToken', 'AccessToken', 'apiKey', 'ApiKey', 'Token', 'token', 'privateToken', 'PrivateToken']);
456
+ return token ? { AccessToken: token } : null;
465
457
  }
466
458
  catch {
467
459
  return null;
468
460
  }
469
- const get = (...keys) => {
470
- for (const k of keys) {
471
- const hit = Object.entries(raw).find(([key]) => key.toLowerCase() === k.toLowerCase());
472
- if (hit && typeof hit[1] === 'string')
473
- return hit[1];
461
+ }
462
+ // ── Path-var substitution helpers ────────────────────────────────
463
+ /**
464
+ * Substitutes EVERY `{var}` in a write path. The record's own id (recordID, when provided) fills the
465
+ * generic `{ID}`/`{id}`/`{ExternalID}` placeholders AND the LAST vendor-named var in the path (the
466
+ * record's own id segment for update/delete, e.g. `{ticket_class_id}` in
467
+ * `/events/{event_id}/ticket_classes/{ticket_class_id}/`). Every OTHER var is a parent id resolved from
468
+ * the resolution map (Attributes ∪ Relationships), matched case-insensitively. Returns the resolved path
469
+ * plus the list of vars that could NOT be resolved (caller fails loudly on a non-empty list).
470
+ */
471
+ SubstituteAllPathVars(path, recordID, attributes, relationships) {
472
+ const vars = this.DetectPathVars(path);
473
+ if (vars.length === 0)
474
+ return { path, unresolved: [] };
475
+ const genericIDNames = new Set(['id', 'ID', 'ExternalID']);
476
+ // The record's own id var is the LAST non-generic var, when a recordID is supplied (update/delete).
477
+ const nonGeneric = vars.filter(v => !genericIDNames.has(v));
478
+ const ownIDVar = recordID != null && nonGeneric.length > 0 ? nonGeneric[nonGeneric.length - 1] : null;
479
+ const resolutionMap = this.BuildResolutionMap(attributes, relationships);
480
+ const unresolved = [];
481
+ let resolvedPath = path;
482
+ for (const v of vars) {
483
+ let value;
484
+ if (genericIDNames.has(v) || v === ownIDVar) {
485
+ value = recordID;
486
+ }
487
+ else {
488
+ value = resolutionMap.get(v.toLowerCase());
489
+ }
490
+ if (value == null || value.length === 0) {
491
+ unresolved.push(v);
492
+ continue;
493
+ }
494
+ resolvedPath = resolvedPath.replace(`{${v}}`, encodeURIComponent(value));
495
+ }
496
+ return { path: resolvedPath, unresolved };
497
+ }
498
+ /**
499
+ * A delete carries no Attributes, so a child-object delete path with a parent var (e.g.
500
+ * `/events/{event_id}/ticket_classes/{...}`) has no place to source the parent id — EXCEPT the composite
501
+ * ExternalID. When the path has >1 var, the ExternalID is expected as `parentId|...|recordId` (the base's
502
+ * composite-PK ExternalID form): the trailing segment is the record id, the leading segments fill the
503
+ * parent vars in path order. When the path has ≤1 var, the whole ExternalID is the record id.
504
+ */
505
+ SplitCompositeExternalID(externalID, path) {
506
+ const vars = this.DetectPathVars(path).filter(v => !['id', 'ID', 'ExternalID'].includes(v));
507
+ if (vars.length <= 1) {
508
+ return { parentTags: {}, recordID: externalID };
509
+ }
510
+ const parts = externalID.split('|');
511
+ const recordID = parts[parts.length - 1];
512
+ const parentTags = {};
513
+ // Leading parts map to the leading (parent) vars in path order.
514
+ const parentVars = vars.slice(0, vars.length - 1);
515
+ for (let i = 0; i < parentVars.length && i < parts.length - 1; i++) {
516
+ parentTags[parentVars[i]] = parts[i];
517
+ }
518
+ return { parentTags, recordID };
519
+ }
520
+ /** Detects `{var}` placeholders in a path. */
521
+ DetectPathVars(path) {
522
+ const matches = path.match(/\{(\w+)\}/g);
523
+ return matches ? matches.map(m => m.slice(1, -1)) : [];
524
+ }
525
+ /** Builds a case-insensitive lookup map of parent-id candidates from Attributes ∪ Relationships. */
526
+ BuildResolutionMap(attributes, relationships) {
527
+ const map = new Map();
528
+ const add = (src) => {
529
+ if (!src)
530
+ return;
531
+ for (const [k, v] of Object.entries(src)) {
532
+ if (v == null)
533
+ continue;
534
+ const s = String(v);
535
+ if (s.length > 0)
536
+ map.set(k.toLowerCase(), s);
474
537
  }
475
- return undefined;
476
538
  };
539
+ add(relationships);
540
+ add(attributes); // attributes win over relationships on a key collision
541
+ return map;
542
+ }
543
+ /** Builds a consistent unresolved-var CRUD failure result (never a broken URL to the wire). */
544
+ UnresolvedVarError(objectName, verb, path, unresolved) {
477
545
  return {
478
- Token: get('PrivateToken', 'Token', 'AccessToken', 'accessToken', 'apiKey', 'api_key', 'key'),
546
+ Success: false,
547
+ StatusCode: 0,
548
+ ErrorMessage: `[eventbrite] ${verb} for "${objectName}" could not resolve path variable(s) ` +
549
+ `[${unresolved.join(', ')}] in "${path}" — supply them in Attributes/Relationships ` +
550
+ `(parent ids) or as the record ExternalID.`,
479
551
  };
480
552
  }
553
+ // ── Watermark helpers ────────────────────────────────────────────
554
+ /**
555
+ * Formats a watermark value into the `changed_since` datetime the API expects (UTC ISO-8601). Accepts an
556
+ * ISO string (passed through) or an epoch-ms string (converted). Eventbrite documents `changed_since` as
557
+ * a UTC datetime; passing an unparseable value through unchanged lets the API reject it loudly rather than
558
+ * silently widening the window.
559
+ */
560
+ FormatChangedSince(watermark) {
561
+ if (/^\d+$/.test(watermark)) {
562
+ return new Date(Number(watermark)).toISOString();
563
+ }
564
+ return watermark;
565
+ }
566
+ /**
567
+ * Computes the new watermark = the max `changed` timestamp across the fetched records, floored at the
568
+ * incoming watermark so it never regresses. Records that don't carry the field are skipped. Returns the
569
+ * incoming watermark unchanged when no record advances it (idempotent no-op next run).
570
+ */
571
+ MaxWatermark(result, watermarkField, incoming) {
572
+ let maxMs = this.ToMs(incoming);
573
+ let maxStr = incoming;
574
+ for (const rec of result.Records) {
575
+ const raw = rec.Fields?.[watermarkField];
576
+ if (raw == null)
577
+ continue;
578
+ const s = String(raw);
579
+ const ms = this.ToMs(s);
580
+ if (ms > maxMs) {
581
+ maxMs = ms;
582
+ maxStr = s;
583
+ }
584
+ }
585
+ return maxStr;
586
+ }
587
+ /** Parses a watermark (ISO datetime or epoch-ms string) to epoch ms; 0 when unparseable/empty. */
588
+ ToMs(value) {
589
+ if (!value || value.length === 0)
590
+ return 0;
591
+ if (/^\d+$/.test(value))
592
+ return Number(value);
593
+ const t = Date.parse(value);
594
+ return isNaN(t) ? 0 : t;
595
+ }
596
+ // ── Misc helpers ─────────────────────────────────────────────────
597
+ /** Returns the first present, non-empty string value among the given keys. */
598
+ FirstString(obj, keys) {
599
+ for (const k of keys) {
600
+ const v = obj[k];
601
+ if (typeof v === 'string' && v.length > 0)
602
+ return v;
603
+ }
604
+ return undefined;
605
+ }
606
+ /**
607
+ * Resolves an IO by name from the engine cache (via this integration's id) without throwing. Returns null
608
+ * when the cache is unavailable (unit-test context) or the object isn't found — StableOrderingKey then
609
+ * degrades to null, a safe default (the engine simply doesn't use keyset resume for that object).
610
+ */
611
+ TryGetActiveObject(objectName) {
612
+ try {
613
+ const integ = IntegrationEngineBase.Instance.GetIntegrationByName(this.IntegrationName);
614
+ if (!integ)
615
+ return null;
616
+ return IntegrationEngineBase.Instance.GetIntegrationObject(integ.ID, objectName) ?? null;
617
+ }
618
+ catch {
619
+ return null;
620
+ }
621
+ }
481
622
  };
482
623
  EventbriteConnector = __decorate([
483
624
  RegisterClass(BaseIntegrationConnector, 'EventbriteConnector')
484
625
  ], EventbriteConnector);
485
626
  export { EventbriteConnector };
486
- /** Tree-shaking prevention function — import and call from the package entry point. */
487
- export function LoadEventbriteConnector() { }
627
+ /**
628
+ * Minimal bounded promise-pool: runs `worker` over `items` with at most `limit` in flight.
629
+ * (BaseRESTIntegrationConnector.RunBounded is private, so the sample-union override brings its own
630
+ * tiny pool — this is local plumbing, NOT a shared framework artifact.)
631
+ */
632
+ async function runBounded(items, limit, worker) {
633
+ const queue = [...items];
634
+ const size = Math.max(1, Math.min(limit, queue.length));
635
+ const runners = Array.from({ length: size }, async () => {
636
+ for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
637
+ await worker(next);
638
+ }
639
+ });
640
+ await Promise.all(runners);
641
+ }
488
642
  //# sourceMappingURL=EventbriteConnector.js.map