@memberjunction/connector-path-lms 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,1281 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { RegisterClass } from '@memberjunction/global';
8
+ import { Metadata } from '@memberjunction/core';
9
+ import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
10
+ import { BaseIntegrationConnector, BaseRESTIntegrationConnector, } from '@memberjunction/integration-engine';
11
+ import { z } from 'zod';
12
+ /**
13
+ * Path LMS Reporting API connector (Blue Sky eLearn).
14
+ *
15
+ * TRANSPORT: GraphQL over HTTP. There is exactly ONE protocol base — {@link BaseRESTIntegrationConnector};
16
+ * GraphQL rides on top of it. Every report query POSTs a GraphQL document to the single endpoint
17
+ * `https://data-api.pathlms.com/graphql`; {@link NormalizeResponse} strips the `data.<queryName>`
18
+ * envelope and surfaces `errors[]`.
19
+ *
20
+ * AUTH (two-step, no refresh token): {@link Authenticate} exchanges `{ applicationId, applicationSecret }`
21
+ * (form-urlencoded) at `https://data-api.pathlms.com/api/v1/getToken` for a ~12h bearer JWT. The token +
22
+ * expiry are cached per credential; on expiry OR a 401, the connector re-mints. applicationId/Secret come
23
+ * from the linked Credential entity (or the CompanyIntegration.Configuration JSON) — NEVER baked in code.
24
+ *
25
+ * PAGINATION: offset/limit (`PaginationType=Offset`) on the report queries.
26
+ *
27
+ * PULL-ONLY: the SDL documents 0 mutations / 0 subscriptions, so SupportsCreate/Update/Delete stay false
28
+ * (the base defaults) and no CRUD path is wired. No 501 stubs.
29
+ *
30
+ * INCREMENTAL: every IO here is `SupportsIncrementalSync=false` — the SDL's startDate/endDate args filter
31
+ * by event date, NOT record-modification time, so there is no watermark to assert (provable-only). The
32
+ * engine relies on content-hash idempotency + {@link StableOrderingKey} (the IO's stable `id`) for
33
+ * keyset-style resume.
34
+ *
35
+ * DISCOVERY — CREDENTIAL-FREE, FROM THE PUBLIC SCHEMA (the T3-deadlock fix):
36
+ * Path LMS publishes its complete GraphQL SDL credential-free as a SpectaQL HTML reference page at
37
+ * `https://data-api.pathlms.com/`. That page is the **schema-of-record**. {@link DiscoverObjects} and
38
+ * {@link DiscoverFields} FETCH + PARSE that public page WITH NO CREDENTIAL and enumerate the full standard
39
+ * universe of GraphQL **record types** (84 = 93 SDL object types − 9 non-record types). This is what makes
40
+ * the runtime credential-free `DocStructureSelfCheck` re-yield the same standard universe every time (so
41
+ * persisted objects never read as "structure drift").
42
+ *
43
+ * A live credential is strictly **ADDITIVE**: when present, {@link DiscoverFields} also runs a standard
44
+ * GraphQL introspection against `/graphql` and appends any tenant-specific fields the public SDL lacked
45
+ * (the `Discovered` extension). It NEVER samples live data at build time and NEVER becomes the baseline —
46
+ * the standard universe always comes from the public, token-free schema. The auth-gated live introspection
47
+ * being available does NOT make the connector "case 2": the same schema is published credential-free, so
48
+ * discovery is case 1 (public schema) + an additive case-2 tenant overlay. The catalog is NOT a module-level
49
+ * constant — it is fetched + parsed from the public page at discovery time (only the small set of non-record
50
+ * SDL types to *exclude* is a documented constant, NOT the catalog itself).
51
+ */
52
+ let PathLMSConnector = class PathLMSConnector extends BaseRESTIntegrationConnector {
53
+ constructor() {
54
+ super(...arguments);
55
+ /** Per-process token cache, keyed by credential identity (applicationId). Survives across fetches. */
56
+ this.tokenCache = new Map();
57
+ /**
58
+ * Per-process cache of the parsed PUBLIC SpectaQL schema (type-name → record type + fields). Populated
59
+ * lazily, credential-free, from `https://data-api.pathlms.com/`. This is the standard-universe source of
60
+ * record for discovery; it is fetched + parsed at runtime, never a baked array.
61
+ */
62
+ this.publicSchemaCache = null;
63
+ this.publicSchemaPromise = null;
64
+ /**
65
+ * Per-CompanyIntegration introspection cache of the live SDL's object types → their scalar/object field
66
+ * shape. Lets the GraphQL selection-set builder emit a valid sub-selection for object-valued (`json`)
67
+ * report fields like `attendees: [WebinarAttendee]!`, and feeds the ADDITIVE tenant-field overlay in
68
+ * DiscoverFields. Populated lazily at runtime from the live introspection query — never a baked catalog.
69
+ */
70
+ this.sdlTypeCache = new Map();
71
+ }
72
+ // ─── Three-way invariant name ─────────────────────────────────────
73
+ /** Verbatim ClassName / IntegrationName getter / MJ: Integrations.Name. */
74
+ get IntegrationName() {
75
+ return 'Path LMS';
76
+ }
77
+ // ─── Sync-efficiency hooks (override only on evidence) ────────────
78
+ /**
79
+ * KEYSET / no-watermark resume hint. None of the Path LMS report queries expose a record-modification
80
+ * watermark, so every object resumes by its stable ordering key — the object's declared primary key
81
+ * (the report's `id`). Resolved from the cached IOF PK. Returns null when no PK is declared (keyset
82
+ * resume unavailable for the PK-less roll-up/aggregate report types).
83
+ */
84
+ StableOrderingKey(objectName) {
85
+ const obj = this.TryGetCachedObject(objectName);
86
+ if (!obj)
87
+ return null;
88
+ const pk = this.GetCachedFields(obj.ID).find(f => f.IsPrimaryKey);
89
+ return pk?.Name ?? null;
90
+ }
91
+ /**
92
+ * Conservative rate-limit policy. Path LMS publishes no explicit per-app limit; a single GraphQL
93
+ * endpoint behind Apollo tolerates modest sustained throughput. A low default keeps the connector
94
+ * polite without a documented number to push to (provable-only — see PROVENANCE BatchRequestWaitTimeGap).
95
+ */
96
+ get RateLimitPolicy() {
97
+ return { TokensPerSec: 5, Burst: 10 };
98
+ }
99
+ // ─── Auth + transport (BaseRESTIntegrationConnector abstracts) ─────
100
+ /**
101
+ * Two-step token exchange. Resolves applicationId/applicationSecret from the credential, returns a
102
+ * cached non-expired token when available, else POSTs form-urlencoded credentials to /api/v1/getToken
103
+ * and caches the resulting bearer (stripping any leading "Bearer ") with a 12h expiry (minus a skew
104
+ * buffer). No refresh token exists — re-mint is just another exchange.
105
+ */
106
+ async Authenticate(companyIntegration, contextUser) {
107
+ const creds = await this.LoadCredentials(companyIntegration, contextUser);
108
+ const token = await this.GetOrMintToken(creds);
109
+ return { Token: token, Credentials: creds, BaseURL: creds.BaseURL };
110
+ }
111
+ /**
112
+ * Builds an auth context from a directly-supplied/pre-configured bearer token WITHOUT a live token
113
+ * exchange. The transport-smoke gate (T7b) configures a dummy token and asserts the connector injects
114
+ * `Authorization: Bearer <token>` on the request — it must NOT require a successful /api/v1/getToken
115
+ * round-trip before a header is present. Whenever a credential/Configuration carries a direct bearer
116
+ * token (alias `Token`/`accessToken`/`bearerToken`/`apiKey`), {@link LoadCredentials} surfaces it as
117
+ * `PreconfiguredToken` and {@link GetOrMintToken} returns it verbatim, so {@link BuildHeaders} sets the
118
+ * header off the configured token state with no network call.
119
+ */
120
+ /** Bearer header on every GraphQL request, plus JSON content negotiation. */
121
+ BuildHeaders(auth) {
122
+ return {
123
+ 'Authorization': `Bearer ${auth.Token ?? ''}`,
124
+ 'Content-Type': 'application/json',
125
+ 'Accept': 'application/json',
126
+ };
127
+ }
128
+ /** The single GraphQL endpoint. APIPath on every IO is `/graphql`, so the base URL is the host root. */
129
+ GetBaseURL(companyIntegration, _auth) {
130
+ const override = companyIntegration.Configuration
131
+ ? this.ParseCredentialJson(companyIntegration.Configuration)?.BaseURL
132
+ : undefined;
133
+ return this.HostFor(override);
134
+ }
135
+ /**
136
+ * Resolves the API host root: an optional `Configuration.BaseURL`/`GraphQLEndpoint` override (a
137
+ * self-hosted/sandbox Path LMS instance, or an e2e mock origin) else the canonical {@link PATHLMS_HOST}.
138
+ * A full `…/graphql` URL is tolerated and reduced to the host root (the connector appends the paths).
139
+ */
140
+ HostFor(override) {
141
+ const o = override?.trim();
142
+ if (!o)
143
+ return PATHLMS_HOST;
144
+ return o.replace(/\/graphql\/?$/i, '').replace(/\/+$/, '');
145
+ }
146
+ /**
147
+ * Executes an HTTP request via fetch. For GraphQL all requests are POSTs carrying a JSON
148
+ * `{ query, variables }` body. Parses the JSON body; returns the raw text on a non-JSON response.
149
+ */
150
+ async MakeHTTPRequest(_auth, url, method, headers, body) {
151
+ const init = { method, headers };
152
+ if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
153
+ init.body = typeof body === 'string' ? body : JSON.stringify(body);
154
+ }
155
+ const response = await fetch(url, init);
156
+ const responseHeaders = {};
157
+ response.headers.forEach((value, key) => { responseHeaders[key.toLowerCase()] = value; });
158
+ const text = await response.text();
159
+ let parsed = text;
160
+ if (text.length > 0) {
161
+ try {
162
+ parsed = JSON.parse(text);
163
+ }
164
+ catch {
165
+ parsed = text;
166
+ }
167
+ }
168
+ return { Status: response.status, Body: parsed, Headers: responseHeaders };
169
+ }
170
+ /**
171
+ * Strips the GraphQL envelope: `{ data: { <responseDataKey>: <records> } }` → records, normalizing a
172
+ * single object to a one-element array. GraphQL errors are surfaced as a thrown error so the engine
173
+ * records the failure (rather than silently treating `data: null` as zero records). When `data` is
174
+ * present alongside `errors` (partial success), the data is returned and the error is left to the
175
+ * caller's HTTP-status handling.
176
+ */
177
+ NormalizeResponse(rawBody, responseDataKey) {
178
+ if (!isRecord(rawBody))
179
+ return [];
180
+ const data = rawBody['data'];
181
+ const errors = rawBody['errors'];
182
+ // A GraphQL error with no usable data is a failure — surface it loudly, don't swallow it as empty.
183
+ if ((data == null) && Array.isArray(errors) && errors.length > 0) {
184
+ throw new Error(`Path LMS GraphQL error: ${formatGraphQLErrors(errors)}`);
185
+ }
186
+ if (!isRecord(data))
187
+ return [];
188
+ const key = responseDataKey ?? '';
189
+ const payload = key.length > 0 ? data[key] : firstValue(data);
190
+ if (Array.isArray(payload))
191
+ return payload.filter(isRecord);
192
+ if (isRecord(payload))
193
+ return [payload];
194
+ return [];
195
+ }
196
+ /**
197
+ * Offset/limit pagination. Path LMS report queries return a flat list with no total-count or
198
+ * has-next signal, so "more pages remain" is inferred from a full page: when the page returned
199
+ * exactly `pageSize` records, there may be more (advance the offset); a short/empty page is the end.
200
+ */
201
+ ExtractPaginationInfo(rawBody, paginationType, _currentPage, currentOffset, pageSize) {
202
+ if (paginationType !== 'Offset')
203
+ return { HasMore: false };
204
+ const records = this.NormalizeResponseSafe(rawBody);
205
+ const pageFull = pageSize > 0 && records.length >= pageSize;
206
+ return {
207
+ HasMore: pageFull,
208
+ NextOffset: currentOffset + records.length,
209
+ };
210
+ }
211
+ // ─── TestConnection ───────────────────────────────────────────────
212
+ /**
213
+ * Verifies credentials by minting a token then issuing the cheapest GraphQL query the SDL documents
214
+ * (`teamsList { id }`). A 401/GraphQL auth error means bad credentials; a 2xx with a `data` envelope
215
+ * confirms both the token exchange and live GraphQL access.
216
+ */
217
+ async TestConnection(companyIntegration, contextUser) {
218
+ try {
219
+ const auth = await this.Authenticate(companyIntegration, contextUser);
220
+ const response = await this.PostGraphQL(auth, 'query { teamsList { id } }', {});
221
+ if (response.Status === 401) {
222
+ return { Success: false, Message: 'Path LMS authentication failed (HTTP 401) — token rejected by /graphql.' };
223
+ }
224
+ if (response.Status < 200 || response.Status >= 300) {
225
+ return { Success: false, Message: `Path LMS /graphql returned HTTP ${response.Status}.` };
226
+ }
227
+ if (isRecord(response.Body) && Array.isArray(response.Body['errors']) && response.Body['data'] == null) {
228
+ return { Success: false, Message: `Path LMS GraphQL error: ${formatGraphQLErrors(response.Body['errors'])}` };
229
+ }
230
+ return { Success: true, Message: 'Successfully connected to Path LMS Reporting API.', ServerVersion: 'Path LMS GraphQL Reporting API' };
231
+ }
232
+ catch (err) {
233
+ const message = err instanceof Error ? err.message : String(err);
234
+ return { Success: false, Message: `Path LMS connection error: ${message}` };
235
+ }
236
+ }
237
+ // ─── Discovery (PUBLIC SpectaQL schema — credential-free baseline) ─
238
+ /**
239
+ * Enumerates the FULL STANDARD UNIVERSE of record types CREDENTIAL-FREE by fetching + parsing the
240
+ * public SpectaQL schema page at `https://data-api.pathlms.com/` — NOT a baked array and NOT dependent
241
+ * on any token or on the seeded metadata cache. This is the T3-deadlock fix: because the standard
242
+ * universe is sourced from the public schema (which always resolves without a credential), the runtime
243
+ * credential-free `DocStructureSelfCheck` re-yields the same universe and persisted objects never read
244
+ * as structure drift.
245
+ *
246
+ * The seeded Declared metadata cache is consulted only to ATTACH the persisted IntegrationObject `ID`
247
+ * to each discovered object (a join, not the source of the object set). A live credential is NOT used
248
+ * here at all — standard objects are token-free by construction.
249
+ */
250
+ async DiscoverObjects(companyIntegration, _contextUser) {
251
+ const schema = await this.GetPublicSchema();
252
+ const cachedByName = this.CachedObjectsByName(companyIntegration.IntegrationID);
253
+ return schema.RecordTypes.map(rt => {
254
+ const cached = cachedByName.get(rt.Name.toLowerCase());
255
+ return {
256
+ ID: cached?.ID,
257
+ Name: rt.Name,
258
+ Label: cached?.DisplayName ?? rt.Name,
259
+ Description: cached?.Description ?? rt.Description ?? undefined,
260
+ // Pull-only reporting surface: no record-modification watermark in the SDL, no mutations.
261
+ SupportsIncrementalSync: cached?.SupportsIncrementalSync ?? false,
262
+ SupportsWrite: cached?.SupportsWrite ?? false,
263
+ };
264
+ });
265
+ }
266
+ /**
267
+ * Returns the fields for an object as the FULL STANDARD set parsed CREDENTIAL-FREE from the public
268
+ * SpectaQL schema (the baseline), enriched by the seeded Declared metadata (PK/FK/type curation) where
269
+ * it exists, then — only when a live credential is present — ADDITIVELY augmented with any tenant-
270
+ * specific fields the live introspection exposes that the public SDL lacked (the `Discovered` overlay).
271
+ *
272
+ * The public-schema field set is the baseline that always resolves token-free. The Declared overlay
273
+ * never replaces it; the live overlay only appends. Introspection failures degrade gracefully to the
274
+ * public + Declared fields. NEVER samples live data; NEVER hardcodes the field catalog.
275
+ */
276
+ async DiscoverFields(companyIntegration, objectName, contextUser) {
277
+ const schema = await this.GetPublicSchema();
278
+ const recordType = schema.RecordTypesByName.get(objectName.toLowerCase());
279
+ // Baseline = the public-schema field set (credential-free). If the object isn't in the public
280
+ // schema (a tenant custom object), fall back to the Declared cache as the baseline.
281
+ const ownerHasIdField = recordType ? recordType.Fields.some(f => f.Name === 'id') : false;
282
+ const baseline = recordType
283
+ ? recordType.Fields.map(f => this.PublicFieldToSchema(f, recordType.Name, schema, ownerHasIdField))
284
+ : this.DeclaredFields(companyIntegration.IntegrationID, objectName);
285
+ const merged = this.MergeDeclaredOverlay(baseline, companyIntegration.IntegrationID, objectName);
286
+ // Additive tenant overlay: only with a live credential, and never as the baseline.
287
+ if (!companyIntegration.CredentialID && !companyIntegration.Configuration)
288
+ return merged;
289
+ return this.AppendLiveFields(merged, companyIntegration, objectName, contextUser, recordType);
290
+ }
291
+ /**
292
+ * Appends any live-introspection fields the public schema lacked (tenant `Discovered` overlay). The
293
+ * public + Declared fields are the floor; the live introspection only ADDS. Degrades to the merged
294
+ * baseline on any introspection failure.
295
+ */
296
+ async AppendLiveFields(baseline, companyIntegration, objectName, contextUser, recordType) {
297
+ try {
298
+ const auth = await this.Authenticate(companyIntegration, contextUser);
299
+ const typeMap = await this.GetSDLTypeMap(companyIntegration, auth);
300
+ const sdlType = recordType ? typeMap[recordType.Name] : typeMap[objectName];
301
+ if (!sdlType)
302
+ return baseline;
303
+ const known = new Set(baseline.map(d => d.Name.toLowerCase()));
304
+ for (const sf of sdlType.Fields) {
305
+ if (known.has(sf.Name.toLowerCase()))
306
+ continue;
307
+ baseline.push({
308
+ Name: sf.Name,
309
+ Label: sf.Name,
310
+ DataType: this.SDLTypeToDataType(sf),
311
+ IsRequired: sf.NonNull,
312
+ IsUniqueKey: false,
313
+ IsReadOnly: true, // pull-only reporting surface — every field is read-only
314
+ });
315
+ }
316
+ return baseline;
317
+ }
318
+ catch (err) {
319
+ const msg = err instanceof Error ? err.message : String(err);
320
+ console.warn(`[Path LMS] live SDL augmentation for "${objectName}" failed (${msg}); using public + Declared fields.`);
321
+ return baseline;
322
+ }
323
+ }
324
+ // ─── FetchChanges (GraphQL query built from IO/IOF metadata) ───────
325
+ /**
326
+ * Fetches records by POSTing a GraphQL document built from the IO's metadata:
327
+ * - operation name + return type + arguments from the IO's per-object Configuration JSON,
328
+ * - the selection set from the IOF field names (scalar fields selected directly; object-valued
329
+ * `json` fields given a one-level sub-selection resolved from the live SDL type map),
330
+ * - offset/limit appended for Offset-paginated queries.
331
+ *
332
+ * Full-record pass-through: every record's COMPLETE GraphQL node lands in `ExternalRecord.Fields` so
333
+ * the framework's custom-column capture sees every key. Identity is the IOF-declared PK; the base
334
+ * content-hash fallback handles PK-less / object-container objects.
335
+ */
336
+ async FetchChanges(ctx) {
337
+ const obj = this.GetCachedObject(ctx.CompanyIntegration.IntegrationID, ctx.ObjectName);
338
+ const fields = this.GetCachedFields(obj.ID);
339
+ const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
340
+ const ioConfig = this.ParseIOConfiguration(obj);
341
+ // No derivable fetch route (AccessPath.Door = null) — skip BEFORE any network call, surface a warning.
342
+ if (ioConfig.Unresolved) {
343
+ return {
344
+ Records: [],
345
+ HasMore: false,
346
+ Warnings: [{ Code: 'NO_ACCESS_PATH', Message: `Path LMS: "${ctx.ObjectName}" has no derivable access path (nested polymorphic type) — not synced as a top-level object.`, Data: { ObjectName: ctx.ObjectName } }],
347
+ };
348
+ }
349
+ const typeMap = await this.GetSDLTypeMapSafe(ctx.CompanyIntegration, auth);
350
+ const selectionSet = this.BuildSelectionSet(ioConfig, fields, typeMap);
351
+ const paginated = obj.SupportsPagination && obj.PaginationType === 'Offset';
352
+ const pageSize = this.ResolvePageSize(ctx, obj);
353
+ let offset = ctx.CurrentOffset ?? 0;
354
+ const batchLimit = ctx.BatchSize && ctx.BatchSize > 0 ? ctx.BatchSize : Number.MAX_SAFE_INTEGER;
355
+ const records = [];
356
+ const pkFieldNames = this.FindPKFieldNames(fields);
357
+ let hasMore = false;
358
+ // Nested record type (tables ≠ doors): query the door, descend the access path to the leaf records.
359
+ if (ioConfig.Segments.length > 0) {
360
+ return this.FetchViaAccessPath(ctx, obj, ioConfig, selectionSet, auth, pkFieldNames);
361
+ }
362
+ for (;;) {
363
+ const { query, variables } = this.BuildQueryDocument(ioConfig, selectionSet, paginated ? { offset, limit: pageSize } : null);
364
+ const response = await this.PostGraphQL(auth, query, variables);
365
+ this.AssertGraphQLOK(response, obj.Name);
366
+ const page = this.NormalizeResponse(response.Body, obj.ResponseDataKey ?? ioConfig.GraphQLQueryName);
367
+ for (const raw of page) {
368
+ records.push({
369
+ ExternalID: this.BuildRecordIdentity(raw, pkFieldNames),
370
+ ObjectType: ctx.ObjectName,
371
+ Fields: raw, // full-record pass-through — the complete GraphQL node
372
+ });
373
+ }
374
+ if (!paginated)
375
+ break;
376
+ const pageFull = page.length >= pageSize && page.length > 0;
377
+ offset += page.length;
378
+ if (!pageFull) {
379
+ hasMore = false;
380
+ break;
381
+ }
382
+ if (records.length >= batchLimit) {
383
+ hasMore = true;
384
+ break;
385
+ }
386
+ }
387
+ const result = { Records: records, HasMore: hasMore };
388
+ if (paginated && hasMore) {
389
+ result.NextOffset = offset;
390
+ }
391
+ return result;
392
+ }
393
+ /**
394
+ * Fetches a NESTED record type by querying its entry "door" and descending the access-path field
395
+ * chain to the leaf record collection (tables ≠ doors). One GraphQL request selects down the path;
396
+ * the response is then walked the same path to flatten out every leaf record. Each leaf carries its
397
+ * FULL node in `Fields` (custom-column pass-through). Reporting doors return the whole set (no
398
+ * offset/limit on the nested path), so HasMore is false.
399
+ */
400
+ async FetchViaAccessPath(ctx, obj, ioConfig, leafSelection, auth, pkFieldNames) {
401
+ const query = this.BuildNestedQuery(ioConfig.GraphQLQueryName, ioConfig.Segments, leafSelection);
402
+ const response = await this.PostGraphQL(auth, query, {});
403
+ this.AssertGraphQLOK(response, obj.Name);
404
+ const dataRoot = isRecord(response.Body) ? response.Body['data'] : null;
405
+ const leaves = this.DescendAccessPath(dataRoot, ioConfig.GraphQLQueryName, ioConfig.Segments);
406
+ const records = leaves.map(raw => ({
407
+ ExternalID: this.BuildRecordIdentity(raw, pkFieldNames),
408
+ ObjectType: ctx.ObjectName,
409
+ Fields: raw, // full-record pass-through — the complete GraphQL node
410
+ }));
411
+ return { Records: records, HasMore: false };
412
+ }
413
+ /** Wraps the leaf selection in the door + nested field chain: `door { seg1 { seg2 { leaf } } }`. */
414
+ BuildNestedQuery(door, segments, leafSelection) {
415
+ let inner = leafSelection;
416
+ for (let i = segments.length - 1; i >= 0; i--) {
417
+ inner = `${segments[i]} { ${inner} }`;
418
+ }
419
+ return `query PathLMS_${door} { ${door} { ${inner} } }`;
420
+ }
421
+ /**
422
+ * Walks a GraphQL response down the access path (`data[door]` → each segment), flattening arrays and
423
+ * single objects at every hop, to yield the flat list of leaf records. Robust to a door/segment that
424
+ * resolves to either an object or an array.
425
+ */
426
+ DescendAccessPath(dataRoot, door, segments) {
427
+ if (!isRecord(dataRoot))
428
+ return [];
429
+ let level = toRecordArray(dataRoot[door]);
430
+ for (const seg of segments) {
431
+ const next = [];
432
+ for (const item of level)
433
+ next.push(...toRecordArray(item[seg]));
434
+ level = next;
435
+ }
436
+ return level;
437
+ }
438
+ // ─── Public SpectaQL schema fetch + parse (credential-free) ────────
439
+ /** Returns the cached public schema, fetching + parsing it once per process (credential-free). */
440
+ async GetPublicSchema() {
441
+ if (this.publicSchemaCache)
442
+ return this.publicSchemaCache;
443
+ if (!this.publicSchemaPromise) {
444
+ this.publicSchemaPromise = this.FetchAndParsePublicSchema()
445
+ .then(schema => { this.publicSchemaCache = schema; return schema; })
446
+ .catch(err => { this.publicSchemaPromise = null; throw err; });
447
+ }
448
+ return this.publicSchemaPromise;
449
+ }
450
+ /**
451
+ * Fetches the public SpectaQL HTML (no credential) and parses its `definition-object` blocks into
452
+ * record types + fields. This is the standard-universe source of record. Allows a subclass / test to
453
+ * override {@link FetchPublicSchemaHTML} to supply a fixture without a network call.
454
+ */
455
+ async FetchAndParsePublicSchema() {
456
+ const html = await this.FetchPublicSchemaHTML();
457
+ return parseSpectaQLSchema(html);
458
+ }
459
+ /**
460
+ * Fetches the raw public SpectaQL schema HTML from `https://data-api.pathlms.com/` with NO auth header.
461
+ * Overridable seam (tests inject a fixture). Throws on a non-2xx response so discovery surfaces a real
462
+ * fetch failure rather than silently returning zero objects.
463
+ */
464
+ async FetchPublicSchemaHTML() {
465
+ const response = await fetch(PUBLIC_SCHEMA_URL, { method: 'GET', headers: { 'Accept': 'text/html' } });
466
+ if (response.status < 200 || response.status >= 300) {
467
+ throw new Error(`Path LMS public schema fetch failed: HTTP ${response.status} from ${PUBLIC_SCHEMA_URL}`);
468
+ }
469
+ return response.text();
470
+ }
471
+ // ─── GraphQL document construction ─────────────────────────────────
472
+ /**
473
+ * Builds the selection set string for the query's return type. Scalar IOF fields are selected
474
+ * directly; object-valued (`json`) IOF fields receive a one-level sub-selection of their SDL type's
475
+ * SCALAR fields when the live type map resolves the field's type. When the type can't be resolved
476
+ * (no introspection / open JSON scalar), the field is selected as a leaf — correct for true scalars,
477
+ * and harmless for unresolved fields (the query simply omits an unresolvable object field rather than
478
+ * sending an invalid leaf selection on a known object type).
479
+ */
480
+ BuildSelectionSet(ioConfig, fields, typeMap) {
481
+ const returnTypeName = stripTypeWrappers(ioConfig.ReturnType);
482
+ const sdlType = typeMap[returnTypeName];
483
+ const parts = [];
484
+ for (const f of fields) {
485
+ const sdlField = sdlType?.Fields.find(sf => sf.Name === f.Name);
486
+ const objectTypeName = sdlField ? this.ObjectFieldTypeName(sdlField, typeMap) : null;
487
+ if (objectTypeName && typeMap[objectTypeName]) {
488
+ const sub = this.BuildScalarSubSelection(typeMap[objectTypeName], typeMap);
489
+ if (sub.length > 0) {
490
+ parts.push(`${f.Name} { ${sub} }`);
491
+ continue;
492
+ }
493
+ // Object field whose type has no scalar leaves we can resolve — skip rather than emit invalid.
494
+ continue;
495
+ }
496
+ // Scalar field (or unresolved type without SDL info) — select as a leaf.
497
+ if (!sdlField || !this.IsObjectField(sdlField, typeMap)) {
498
+ parts.push(f.Name);
499
+ }
500
+ }
501
+ // Always guarantee a non-empty selection — fall back to the PK or `id` so the query is valid.
502
+ if (parts.length === 0) {
503
+ const pk = fields.find(f => f.IsPrimaryKey)?.Name ?? 'id';
504
+ parts.push(pk);
505
+ }
506
+ return parts.join(' ');
507
+ }
508
+ /** Builds a one-level scalar sub-selection for an object type (no nested object recursion). */
509
+ BuildScalarSubSelection(type, typeMap) {
510
+ const scalars = type.Fields.filter(sf => !this.IsObjectField(sf, typeMap)).map(sf => sf.Name);
511
+ return scalars.join(' ');
512
+ }
513
+ /**
514
+ * Assembles the full GraphQL query document and its variables. Operation arguments come from the IO's
515
+ * Configuration `OperationArguments` (e.g. `["limit:Int","offset:Int","teamIds:[Int!]"]`). Only the
516
+ * pagination args (offset/limit) are bound at fetch time when paginating; all other documented filter
517
+ * args are declared as optional variables left unset (the source returns the unfiltered report).
518
+ */
519
+ BuildQueryDocument(ioConfig, selectionSet, page) {
520
+ const argSpecs = ioConfig.OperationArguments.map(parseArgSpec).filter((a) => a != null);
521
+ const variables = {};
522
+ const varDecls = [];
523
+ const callArgs = [];
524
+ for (const arg of argSpecs) {
525
+ const isPaginationArg = arg.Name === 'offset' || arg.Name === 'limit';
526
+ if (page && isPaginationArg) {
527
+ varDecls.push(`$${arg.Name}: ${arg.Type}`);
528
+ callArgs.push(`${arg.Name}: $${arg.Name}`);
529
+ variables[arg.Name] = arg.Name === 'offset' ? page.offset : page.limit;
530
+ }
531
+ // Non-pagination filter args are intentionally not bound — full unfiltered report pull.
532
+ }
533
+ const opName = ioConfig.GraphQLQueryName;
534
+ const decl = varDecls.length > 0 ? `(${varDecls.join(', ')})` : '';
535
+ const call = callArgs.length > 0 ? `(${callArgs.join(', ')})` : '';
536
+ const query = `query PathLMS_${opName}${decl} { ${opName}${call} { ${selectionSet} } }`;
537
+ return { query, variables };
538
+ }
539
+ // ─── Live SDL introspection (the tenant-overlay MECHANISM) ─────────
540
+ /** Returns the cached/freshly-introspected SDL type map; throws on hard introspection failure. */
541
+ async GetSDLTypeMap(companyIntegration, auth) {
542
+ const key = companyIntegration.ID ?? auth.Credentials.applicationId ?? auth.Credentials.PreconfiguredToken ?? '';
543
+ const cached = this.sdlTypeCache.get(key);
544
+ if (cached)
545
+ return cached;
546
+ const response = await this.PostGraphQL(auth, INTROSPECTION_QUERY, {});
547
+ this.AssertGraphQLOK(response, '__schema introspection');
548
+ const typeMap = this.ParseIntrospection(response.Body);
549
+ this.sdlTypeCache.set(key, typeMap);
550
+ return typeMap;
551
+ }
552
+ /** Best-effort SDL type map — returns an empty map (no augmentation) on any introspection failure. */
553
+ async GetSDLTypeMapSafe(companyIntegration, auth) {
554
+ try {
555
+ return await this.GetSDLTypeMap(companyIntegration, auth);
556
+ }
557
+ catch (err) {
558
+ const msg = err instanceof Error ? err.message : String(err);
559
+ console.warn(`[Path LMS] SDL introspection unavailable (${msg}); building selection sets from Declared fields only.`);
560
+ return {};
561
+ }
562
+ }
563
+ /** Parses a GraphQL `__schema.types` introspection response into a name→SDLType map (OBJECT types only). */
564
+ ParseIntrospection(body) {
565
+ const result = IntrospectionResponseSchema.safeParse(body);
566
+ if (!result.success)
567
+ return {};
568
+ const types = result.data.data.__schema.types;
569
+ const map = {};
570
+ for (const t of types) {
571
+ if (t.kind !== 'OBJECT' || !t.name || t.name.startsWith('__'))
572
+ continue;
573
+ const fields = (t.fields ?? []).map(f => ({
574
+ Name: f.name,
575
+ ...flattenIntrospectionType(f.type),
576
+ }));
577
+ map[t.name] = { Name: t.name, Fields: fields };
578
+ }
579
+ return map;
580
+ }
581
+ /** True when a field's underlying named type is an OBJECT type present in the map (needs sub-selection). */
582
+ IsObjectField(field, typeMap) {
583
+ return typeMap[field.NamedType] != null;
584
+ }
585
+ /** Returns the field's underlying OBJECT type name when it is an object (else null). */
586
+ ObjectFieldTypeName(field, typeMap) {
587
+ return typeMap[field.NamedType] ? field.NamedType : null;
588
+ }
589
+ /** Maps an SDL field's underlying type into the connector's coarse DataType vocabulary. */
590
+ SDLTypeToDataType(field) {
591
+ if (this.IsListOrObjectScalar(field))
592
+ return 'json';
593
+ switch (field.NamedType) {
594
+ case 'Int': return 'Int';
595
+ case 'Float': return 'Float';
596
+ case 'Boolean': return 'Boolean';
597
+ case 'Date':
598
+ case 'DateTime': return 'Date';
599
+ default: return 'String';
600
+ }
601
+ }
602
+ IsListOrObjectScalar(field) {
603
+ return field.IsList || field.NamedType === 'JSON';
604
+ }
605
+ // ─── GraphQL request helper ────────────────────────────────────────
606
+ /** POSTs a `{ query, variables }` document to the GraphQL endpoint with auth headers. */
607
+ async PostGraphQL(auth, query, variables) {
608
+ const url = `${this.HostFor(auth.BaseURL)}${GRAPHQL_PATH}`;
609
+ return this.MakeHTTPRequest(auth, url, 'POST', this.BuildHeaders(auth), { query, variables });
610
+ }
611
+ /** Throws on a non-2xx HTTP status or a GraphQL error with no usable data. */
612
+ AssertGraphQLOK(response, context) {
613
+ if (response.Status < 200 || response.Status >= 300) {
614
+ throw new Error(`Path LMS GraphQL request for "${context}" failed: HTTP ${response.Status}`);
615
+ }
616
+ if (isRecord(response.Body)) {
617
+ const errors = response.Body['errors'];
618
+ if (Array.isArray(errors) && errors.length > 0 && response.Body['data'] == null) {
619
+ throw new Error(`Path LMS GraphQL error for "${context}": ${formatGraphQLErrors(errors)}`);
620
+ }
621
+ }
622
+ }
623
+ // ─── Token minting + caching ───────────────────────────────────────
624
+ /**
625
+ * Resolves the bearer token for these credentials. Precedence:
626
+ * 1. A directly-supplied/pre-configured bearer token (`PreconfiguredToken`) is returned verbatim with
627
+ * NO network exchange — this is the path the transport-smoke gate (T7b) exercises with a dummy token,
628
+ * and the path a broker uses when it injects a ready bearer rather than appId/secret.
629
+ * 2. Else a cached, non-expired minted token.
630
+ * 3. Else mint a fresh token via the two-step /api/v1/getToken exchange and cache it (12h, minus skew).
631
+ */
632
+ async GetOrMintToken(creds) {
633
+ if (creds.PreconfiguredToken && creds.PreconfiguredToken.trim().length > 0) {
634
+ return creds.PreconfiguredToken.replace(/^Bearer\s+/i, '').trim();
635
+ }
636
+ const cacheKey = creds.applicationId ?? '';
637
+ const cached = this.tokenCache.get(cacheKey);
638
+ if (cached && cached.ExpiresAt > Date.now() + TOKEN_EXPIRY_SKEW_MS) {
639
+ return cached.Token;
640
+ }
641
+ const token = await this.MintToken(creds);
642
+ this.tokenCache.set(cacheKey, {
643
+ Token: token,
644
+ ExpiresAt: Date.now() + TOKEN_LIFETIME_MS,
645
+ });
646
+ return token;
647
+ }
648
+ /**
649
+ * Exchanges applicationId/applicationSecret for a bearer token via a form-urlencoded POST to
650
+ * /api/v1/getToken. The response carries `{ token: "Bearer <jwt>" }`; the leading "Bearer " is
651
+ * stripped so BuildHeaders applies a single prefix.
652
+ */
653
+ async MintToken(creds) {
654
+ if (!creds.applicationId || !creds.applicationSecret) {
655
+ throw new Error('Path LMS: applicationId + applicationSecret are required to mint a token (no pre-configured token supplied).');
656
+ }
657
+ const url = `${this.HostFor(creds.BaseURL)}${TOKEN_PATH}`;
658
+ const form = new URLSearchParams();
659
+ form.set('applicationId', creds.applicationId);
660
+ form.set('applicationSecret', creds.applicationSecret);
661
+ const response = await fetch(url, {
662
+ method: 'POST',
663
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
664
+ body: form.toString(),
665
+ });
666
+ const text = await response.text();
667
+ if (response.status < 200 || response.status >= 300) {
668
+ throw new Error(`Path LMS token exchange failed: HTTP ${response.status}`);
669
+ }
670
+ let parsed = text;
671
+ try {
672
+ parsed = JSON.parse(text);
673
+ }
674
+ catch { /* leave as text */ }
675
+ return this.ExtractToken(parsed);
676
+ }
677
+ /** Reads the token from the getToken response and strips any leading "Bearer " prefix. */
678
+ ExtractToken(body) {
679
+ let raw;
680
+ if (typeof body === 'string') {
681
+ raw = body;
682
+ }
683
+ else if (isRecord(body)) {
684
+ const candidate = body['token'] ?? body['accessToken'] ?? body['access_token'];
685
+ if (typeof candidate === 'string')
686
+ raw = candidate;
687
+ }
688
+ if (!raw || raw.trim().length === 0) {
689
+ throw new Error('Path LMS token exchange succeeded but the response contained no token.');
690
+ }
691
+ return raw.replace(/^Bearer\s+/i, '').trim();
692
+ }
693
+ // ─── Credential resolution ─────────────────────────────────────────
694
+ /**
695
+ * Resolves applicationId/applicationSecret from the linked Credential entity, falling back to the
696
+ * CompanyIntegration.Configuration JSON. Credentials are issued by Blue Sky eLearn and are NEVER
697
+ * hardcoded in connector code.
698
+ */
699
+ async LoadCredentials(companyIntegration, contextUser) {
700
+ const credentialID = companyIntegration.CredentialID;
701
+ if (credentialID) {
702
+ const fromCred = await this.LoadFromCredentialEntity(credentialID, contextUser);
703
+ if (fromCred)
704
+ return fromCred;
705
+ }
706
+ const configJson = companyIntegration.Configuration;
707
+ if (configJson) {
708
+ const fromConfig = this.ParseCredentialJson(configJson);
709
+ if (fromConfig)
710
+ return fromConfig;
711
+ }
712
+ throw new Error('Path LMS: no credential or Configuration JSON found — applicationId + applicationSecret are required.');
713
+ }
714
+ /** Loads credentials from a Credential entity's Values JSON. */
715
+ async LoadFromCredentialEntity(credentialID, contextUser, provider) {
716
+ const md = provider ?? new Metadata();
717
+ const credential = await md.GetEntityObject('MJ: Credentials', contextUser);
718
+ const loaded = await credential.Load(credentialID);
719
+ if (!loaded || !credential.Values)
720
+ return null;
721
+ return this.ParseCredentialJson(credential.Values);
722
+ }
723
+ /**
724
+ * Parses a JSON string into credentials (tolerant of casing aliases). Accepts EITHER a pre-minted bearer
725
+ * token (`Token`/`accessToken`/`bearerToken`/`apiKey` — the exchange-free path the transport-smoke gate
726
+ * uses) OR an applicationId + applicationSecret pair for the two-step exchange. Returns null only when
727
+ * neither a usable token nor a full appId+secret pair is present.
728
+ */
729
+ ParseCredentialJson(json) {
730
+ try {
731
+ const result = PathLMSCredentialSchema.safeParse(JSON.parse(json));
732
+ if (!result.success)
733
+ return null;
734
+ const p = result.data;
735
+ const preconfiguredToken = p.Token ?? p.token ?? p.accessToken ?? p.access_token ?? p.bearerToken ?? p.apiKey ?? p.APIKey;
736
+ const appId = p.applicationId ?? p.ApplicationID ?? p.clientId ?? p.ClientID;
737
+ const appSecret = p.applicationSecret ?? p.ApplicationSecret ?? p.clientSecret ?? p.ClientSecret;
738
+ const hasToken = typeof preconfiguredToken === 'string' && preconfiguredToken.trim().length > 0;
739
+ const hasExchangePair = !!appId && !!appSecret;
740
+ if (!hasToken && !hasExchangePair)
741
+ return null;
742
+ const baseOverride = p.BaseURL ?? p.baseUrl ?? p.GraphQLEndpoint;
743
+ return {
744
+ applicationId: appId,
745
+ applicationSecret: appSecret,
746
+ PreconfiguredToken: hasToken ? preconfiguredToken : undefined,
747
+ BaseURL: typeof baseOverride === 'string' && baseOverride.trim().length > 0 ? baseOverride : undefined,
748
+ };
749
+ }
750
+ catch {
751
+ return null;
752
+ }
753
+ }
754
+ // ─── Cached-metadata helpers ───────────────────────────────────────
755
+ /** Gets an IO from the cache without throwing (used by StableOrderingKey, which may be called early). */
756
+ TryGetCachedObject(objectName) {
757
+ try {
758
+ const integ = IntegrationEngineBase.Instance.GetIntegrationByName(this.IntegrationName);
759
+ if (!integ)
760
+ return null;
761
+ return IntegrationEngineBase.Instance.GetIntegrationObject(integ.ID, objectName) ?? null;
762
+ }
763
+ catch {
764
+ return null;
765
+ }
766
+ }
767
+ /** Returns the seeded Declared IO rows for this integration keyed by lower-cased name (safe on empty cache). */
768
+ CachedObjectsByName(integrationID) {
769
+ const map = new Map();
770
+ try {
771
+ const objects = IntegrationEngineBase.Instance.GetActiveIntegrationObjects(integrationID);
772
+ for (const obj of objects)
773
+ map.set(obj.Name.toLowerCase(), obj);
774
+ }
775
+ catch {
776
+ /* cache not available in this context — public schema carries the universe regardless */
777
+ }
778
+ return map;
779
+ }
780
+ /** Returns the seeded Declared fields for an object as ExternalFieldSchema (empty when not cached). */
781
+ DeclaredFields(integrationID, objectName) {
782
+ try {
783
+ const obj = IntegrationEngineBase.Instance.GetIntegrationObject(integrationID, objectName);
784
+ if (!obj)
785
+ return [];
786
+ return this.GetCachedFields(obj.ID).map(f => this.DeclaredFieldToSchema(f));
787
+ }
788
+ catch {
789
+ return [];
790
+ }
791
+ }
792
+ /**
793
+ * Overlays the seeded Declared field curation (PK / FK target / type / required) onto the public-schema
794
+ * baseline, by field name. Declared wins for the curated structural attributes where it has an opinion;
795
+ * the public-schema baseline supplies everything else and guarantees the full field set even when no
796
+ * Declared row exists. Never drops a public field; only enriches.
797
+ */
798
+ MergeDeclaredOverlay(baseline, integrationID, objectName) {
799
+ const declared = this.DeclaredFields(integrationID, objectName);
800
+ if (declared.length === 0)
801
+ return baseline;
802
+ const declaredByName = new Map(declared.map(d => [d.Name.toLowerCase(), d]));
803
+ const merged = baseline.map(b => {
804
+ const d = declaredByName.get(b.Name.toLowerCase());
805
+ if (!d)
806
+ return b;
807
+ return {
808
+ ...b,
809
+ Label: d.Label || b.Label,
810
+ Description: d.Description ?? b.Description,
811
+ DataType: d.DataType || b.DataType,
812
+ IsRequired: d.IsRequired,
813
+ IsPrimaryKey: d.IsPrimaryKey ?? b.IsPrimaryKey,
814
+ IsUniqueKey: d.IsUniqueKey || b.IsUniqueKey,
815
+ IsForeignKey: d.IsForeignKey ?? b.IsForeignKey,
816
+ ForeignKeyTarget: d.ForeignKeyTarget ?? b.ForeignKeyTarget,
817
+ };
818
+ });
819
+ // Declared fields the public schema didn't carry (rare) — append so nothing is lost.
820
+ const baselineNames = new Set(baseline.map(b => b.Name.toLowerCase()));
821
+ for (const d of declared) {
822
+ if (!baselineNames.has(d.Name.toLowerCase()))
823
+ merged.push(d);
824
+ }
825
+ return merged;
826
+ }
827
+ /**
828
+ * Maps a parsed public-schema record field into the ExternalFieldSchema baseline shape — applying the
829
+ * EXACT id-PK / typed-reference-FK rules the persisted metadata was built from, so the connector's
830
+ * credential-free discovery reproduces the persisted PK/FK set and T3 `DocStructureSelfCheck` cannot
831
+ * drift. This is run with zero credential and uses ONLY the parsed public SDL universe ({@param schema}).
832
+ *
833
+ * PK: the row's own `id` field is the sole primary key. An id-less type has NO PK (the engine's
834
+ * content-hash carries identity). A `*Id` reference is NEVER a PK — it is a foreign key (below).
835
+ *
836
+ * FK (two cases, mirroring `parse-sdl-fk.mjs` + the id-less `*Id` demotion path):
837
+ * (1) TYPED REFERENCE — the field's unwrapped SDL type resolves to ANOTHER emitted record type
838
+ * (e.g. `groups: [Group]` → Group, `assessmentsReport: [Assessment]!` → Assessment).
839
+ * (2) SCALAR `<Type>Id` — a scalar field named `<emittedType>Id` (case-insensitive, e.g. userId→User,
840
+ * courseId→Course, webinarId→Webinar) where the capitalized stem matches another emitted record
841
+ * type. (No self-alias exclusion: the metadata marks even `userId`-same-as-id as an FK→User on the
842
+ * id-less report rows, so discovery must too.)
843
+ * Both cases exclude a self-reference to the field's own owning type.
844
+ */
845
+ PublicFieldToSchema(f, ownTypeName, schema, ownerHasIdField) {
846
+ const isPK = f.Name === 'id';
847
+ const fkTarget = isPK ? null : this.ResolveFKTarget(f, ownTypeName, schema, ownerHasIdField);
848
+ return {
849
+ Name: f.Name,
850
+ Label: f.Name,
851
+ Description: f.Description ?? undefined,
852
+ DataType: sdlTypeToDataType(f.TargetType, f.IsList),
853
+ // The reporting surface declares non-null with `!`; treat `!` as required-at-read (read-only).
854
+ IsRequired: f.NonNull,
855
+ IsPrimaryKey: isPK,
856
+ IsUniqueKey: isPK,
857
+ IsReadOnly: true, // pull-only reporting surface — every field is read-only
858
+ IsForeignKey: fkTarget != null,
859
+ ForeignKeyTarget: fkTarget,
860
+ };
861
+ }
862
+ /**
863
+ * Resolves a field's foreign-key target to another emitted record type, or null when the field is not
864
+ * a reference. Mirrors EXACTLY the rules `scripts/parse-sdl-fk.mjs` used to author the persisted metadata,
865
+ * so the connector's credential-free FK derivation reproduces it (T3 `DocStructureSelfCheck` cannot drift):
866
+ *
867
+ * (1) TYPED REFERENCE — the SDL anchor target is itself an emitted record type (e.g. `groups: [Group]`
868
+ * → Group). Always an FK; no contradiction exclusion.
869
+ * (2) SCALAR `<Type>Id` — the field name (sans trailing `Id`, capitalized) matches an emitted record
870
+ * type and the field's underlying type is a scalar (e.g. userId→User, courseId→Course, webinarId→
871
+ * Webinar). This IS an FK, with ONE exclusion: when the owning type has its own `id` primary key
872
+ * AND the field's prose marks it a self-alias of that id ("the userId field is the same as id…",
873
+ * "alias of id", "for cross referencing"), it is a renamed view of the row's own identity, not a
874
+ * reference to another row — the persisted metadata leaves those non-FK (e.g. `Order.userId`).
875
+ * On an id-LESS report row (`CategorySale.userId`, `InPersonEventUser.userId`, …) the `<Type>Id`
876
+ * IS the only identity/reference, so the self-alias exclusion does NOT apply and the FK is kept —
877
+ * exactly as the metadata records it.
878
+ *
879
+ * Returns the CANONICAL record-type name (so the FK target matches the persisted
880
+ * `@lookup:…Name=<Type>` exactly). Never resolves to the field's own owning type.
881
+ */
882
+ ResolveFKTarget(f, ownTypeName, schema, ownerHasIdField) {
883
+ const target = f.TargetType;
884
+ if (!target)
885
+ return null;
886
+ // (1) Typed reference — the anchor is a non-scalar emitted record type.
887
+ if (!GRAPHQL_SCALARS.has(target)) {
888
+ const canonical = schema.RecordTypesByName.get(target.toLowerCase());
889
+ if (canonical && canonical.Name !== ownTypeName)
890
+ return canonical.Name;
891
+ return null;
892
+ }
893
+ // (2) Scalar `<Type>Id` — the capitalized stem matches an emitted record type.
894
+ if (f.Name.length > 2 && f.Name.toLowerCase() !== 'id' && /Id$/.test(f.Name)) {
895
+ const stem = f.Name.slice(0, -2);
896
+ const candidate = schema.RecordTypesByName.get(stem.toLowerCase());
897
+ if (!candidate || candidate.Name === ownTypeName)
898
+ return null;
899
+ // Self-alias contradiction (only on types that own an `id`): the `*Id` renames this row's own
900
+ // identity rather than referencing another row — not an FK (matches parse-sdl-fk.mjs).
901
+ if (ownerHasIdField && isSelfAliasOfId(f.Description))
902
+ return null;
903
+ return candidate.Name;
904
+ }
905
+ return null;
906
+ }
907
+ /** Parses the per-IO Configuration JSON into the GraphQL query model (incl. the nested access path). */
908
+ ParseIOConfiguration(obj) {
909
+ const raw = obj.Configuration;
910
+ const fallbackName = obj.ResponseDataKey ?? obj.Name;
911
+ if (!raw) {
912
+ return { GraphQLQueryName: fallbackName, ReturnType: '', OperationArguments: [], Segments: [], Unresolved: false };
913
+ }
914
+ const parsed = IOConfigSchema.safeParse(JSON.parse(raw));
915
+ if (!parsed.success) {
916
+ return { GraphQLQueryName: fallbackName, ReturnType: '', OperationArguments: [], Segments: [], Unresolved: false };
917
+ }
918
+ const ap = parsed.data.AccessPath;
919
+ // An AccessPath whose Door is explicitly null = no derivable fetch route (e.g. polymorphic survey-question subtypes).
920
+ const unresolved = ap != null && (ap.Door == null || ap.Door.trim().length === 0);
921
+ return {
922
+ // AccessPath.Door (the entry query) wins when present; else the legacy flat GraphQLQueryName.
923
+ GraphQLQueryName: ap?.Door ?? parsed.data.GraphQLQueryName ?? fallbackName,
924
+ ReturnType: parsed.data.ReturnType ?? '',
925
+ OperationArguments: parsed.data.OperationArguments ?? [],
926
+ // Segments = the access-path AFTER the door. Explicit `Segments` wins; otherwise derive from
927
+ // `NestingPath` (which includes the door as element 0) by dropping the door and stripping the
928
+ // `[]` array-hop markers so `DescendAccessPath` can index each field key directly.
929
+ Segments: ap?.Segments ?? (ap?.NestingPath && ap.NestingPath.length > 1
930
+ ? ap.NestingPath.slice(1).map(s => s.replace(/\[\]$/, ''))
931
+ : []),
932
+ Unresolved: unresolved,
933
+ };
934
+ }
935
+ /** Returns the IOF PK field names (sorted by sequence), or empty when none is declared. */
936
+ FindPKFieldNames(fields) {
937
+ return fields
938
+ .filter(f => f.IsPrimaryKey)
939
+ .sort((a, b) => a.Sequence - b.Sequence)
940
+ .map(f => f.Name);
941
+ }
942
+ /**
943
+ * Builds a stable record identity from the declared PK fields. When all PK parts are present, joins
944
+ * them with '|'; otherwise returns '' so the engine's content-hash identity fallback takes over (the
945
+ * `account`/`teams` container objects without a usable PK dedupe by content hash).
946
+ */
947
+ BuildRecordIdentity(raw, pkFieldNames) {
948
+ if (pkFieldNames.length === 0)
949
+ return '';
950
+ const parts = [];
951
+ for (const name of pkFieldNames) {
952
+ const v = raw[name];
953
+ if (v == null)
954
+ return '';
955
+ const s = typeof v === 'object' ? JSON.stringify(v) : String(v);
956
+ if (s.length === 0)
957
+ return '';
958
+ parts.push(s);
959
+ }
960
+ return parts.join('|');
961
+ }
962
+ /** Resolves the page size for a fetch: explicit BatchSize, else the IO default, else 50. */
963
+ ResolvePageSize(ctx, obj) {
964
+ if (ctx.BatchSize && ctx.BatchSize > 0)
965
+ return Math.min(ctx.BatchSize, obj.DefaultPageSize ?? DEFAULT_PAGE_SIZE);
966
+ return obj.DefaultPageSize ?? DEFAULT_PAGE_SIZE;
967
+ }
968
+ /** NormalizeResponse variant that never throws — used by ExtractPaginationInfo to count a page. */
969
+ NormalizeResponseSafe(rawBody) {
970
+ if (!isRecord(rawBody) || !isRecord(rawBody['data']))
971
+ return [];
972
+ const data = rawBody['data'];
973
+ const payload = firstValue(data);
974
+ if (Array.isArray(payload))
975
+ return payload.filter(isRecord);
976
+ if (isRecord(payload))
977
+ return [payload];
978
+ return [];
979
+ }
980
+ /** Converts an IntegrationObjectField entity to the ExternalFieldSchema shape (Declared discovery). */
981
+ DeclaredFieldToSchema(f) {
982
+ return {
983
+ Name: f.Name,
984
+ Label: f.DisplayName ?? f.Name,
985
+ Description: f.Description ?? undefined,
986
+ DataType: f.Type,
987
+ IsRequired: f.IsRequired,
988
+ IsPrimaryKey: f.IsPrimaryKey,
989
+ IsUniqueKey: f.IsUniqueKey || f.IsPrimaryKey,
990
+ IsReadOnly: f.IsReadOnly,
991
+ IsForeignKey: f.RelatedIntegrationObjectID != null,
992
+ ForeignKeyTarget: f.RelatedIntegrationObject ?? null,
993
+ };
994
+ }
995
+ };
996
+ PathLMSConnector = __decorate([
997
+ RegisterClass(BaseIntegrationConnector, '@memberjunction/connector-path-lms')
998
+ ], PathLMSConnector);
999
+ export { PathLMSConnector };
1000
+ // ─── Module-level constants + helpers (mechanism, NOT a catalog) ──────
1001
+ /** Path LMS Reporting API host root. */
1002
+ const PATHLMS_HOST = 'https://data-api.pathlms.com';
1003
+ /** GraphQL endpoint path (unversioned). */
1004
+ const GRAPHQL_PATH = '/graphql';
1005
+ /** Two-step token exchange endpoint. */
1006
+ const TOKEN_PATH = '/api/v1/getToken';
1007
+ /** The credential-free PUBLIC SpectaQL schema reference page — the standard-universe schema of record. */
1008
+ const PUBLIC_SCHEMA_URL = 'https://data-api.pathlms.com/';
1009
+ /** Token lifetime per docs (~12h). */
1010
+ const TOKEN_LIFETIME_MS = 12 * 60 * 60 * 1000;
1011
+ /** Re-mint a little before the documented expiry to avoid mid-request expiry. */
1012
+ const TOKEN_EXPIRY_SKEW_MS = 5 * 60 * 1000;
1013
+ /** Default page size when neither BatchSize nor IO DefaultPageSize is set (docs default). */
1014
+ const DEFAULT_PAGE_SIZE = 50;
1015
+ /**
1016
+ * Built-in GraphQL/Path-LMS scalar leaf types — anything NOT in this set that resolves to an emitted record
1017
+ * type is a typed-reference foreign key. Used by {@link PathLMSConnector.ResolveFKTarget} to distinguish a
1018
+ * scalar `<Type>Id` FK from a typed-object FK. Matches the SCALARS set in `scripts/parse-sdl-fk.mjs`.
1019
+ */
1020
+ const GRAPHQL_SCALARS = new Set([
1021
+ 'Int', 'String', 'ID', 'Boolean', 'Float', 'Date', 'DateTime', 'JSON', 'Currency',
1022
+ ]);
1023
+ /**
1024
+ * Non-record SDL OBJECT types to EXCLUDE from the discovered universe. This is the documented exclusion
1025
+ * RULE (abstract bases inlined onto their concrete type, report-container pagination wrappers replaced by
1026
+ * their record types, and id-less value objects that are embedded structs, not tables) — it is NOT the
1027
+ * catalog. The catalog (the 84 record types + their fields) is FETCHED + PARSED from the public schema at
1028
+ * runtime; this set merely removes the 9 non-record blocks from the parsed result.
1029
+ */
1030
+ const NON_RECORD_SDL_TYPES = new Set([
1031
+ 'BaseAccount', // abstract base — fields inlined onto Account
1032
+ 'BaseTeam', // abstract base — fields inlined onto Team
1033
+ 'CourseItemViewReport', // report-container wrapper around CourseItemView
1034
+ 'UserPresentationReport', // report-container wrapper around UserPresentation
1035
+ 'WebinarArchiveViewerReport', // report-container wrapper around WebinarArchiveViewerUser
1036
+ 'WebinarCancellationReport', // report-container wrapper around WebinarCancellationUser
1037
+ 'WebinarGuestReport', // report-container wrapper (envelope only, no own identity)
1038
+ 'SurveyQuestionAnswer', // id-less value object — embedded answer struct, not a table
1039
+ 'SurveySummativeInfo', // id-less value object — embedded summary struct on Survey
1040
+ ]);
1041
+ /** Credential JSON shape (tolerant of casing aliases + a direct/pre-minted bearer token). */
1042
+ const PathLMSCredentialSchema = z.object({
1043
+ applicationId: z.string().optional(),
1044
+ ApplicationID: z.string().optional(),
1045
+ clientId: z.string().optional(),
1046
+ ClientID: z.string().optional(),
1047
+ applicationSecret: z.string().optional(),
1048
+ ApplicationSecret: z.string().optional(),
1049
+ clientSecret: z.string().optional(),
1050
+ ClientSecret: z.string().optional(),
1051
+ // Direct/pre-minted bearer token aliases (exchange-free path; transport-smoke dummy token).
1052
+ Token: z.string().optional(),
1053
+ token: z.string().optional(),
1054
+ accessToken: z.string().optional(),
1055
+ access_token: z.string().optional(),
1056
+ bearerToken: z.string().optional(),
1057
+ apiKey: z.string().optional(),
1058
+ APIKey: z.string().optional(),
1059
+ // Optional host override (a self-hosted/sandbox Path LMS instance, or an e2e mock origin). Absent ⇒
1060
+ // the canonical production host. The value is the host ROOT; the connector appends `/graphql` and
1061
+ // `/api/v1/getToken` itself (a full `…/graphql` URL is tolerated and reduced to the root).
1062
+ BaseURL: z.string().optional(),
1063
+ baseUrl: z.string().optional(),
1064
+ GraphQLEndpoint: z.string().optional(),
1065
+ }).passthrough();
1066
+ /** Per-IO Configuration JSON shape carrying the GraphQL query model. */
1067
+ const IOConfigSchema = z.object({
1068
+ GraphQLQueryName: z.string().optional(),
1069
+ ReturnType: z.string().optional(),
1070
+ OperationArguments: z.array(z.string()).optional(),
1071
+ // Nested access path (tables ≠ doors): the entry query + the field-name chain down to this record.
1072
+ AccessPath: z.object({
1073
+ Door: z.string().nullable().optional(),
1074
+ Segments: z.array(z.string()).optional(),
1075
+ // Discovery emits NestingPath (door + field-name chain, array hops marked `field[]`) + Depth.
1076
+ // Segments (the path AFTER the door, markers stripped) is derived from it when not explicit.
1077
+ NestingPath: z.array(z.string()).optional(),
1078
+ Depth: z.number().optional(),
1079
+ }).optional(),
1080
+ }).passthrough();
1081
+ /** Minimal GraphQL introspection response shape (only the bits the type-map builder reads). */
1082
+ const IntrospectionTypeRefSchema = z.lazy(() => z.object({
1083
+ kind: z.string(),
1084
+ name: z.string().nullish(),
1085
+ ofType: IntrospectionTypeRefSchema.nullish(),
1086
+ }));
1087
+ const IntrospectionResponseSchema = z.object({
1088
+ data: z.object({
1089
+ __schema: z.object({
1090
+ types: z.array(z.object({
1091
+ kind: z.string(),
1092
+ name: z.string().nullable(),
1093
+ fields: z.array(z.object({
1094
+ name: z.string(),
1095
+ type: IntrospectionTypeRefSchema,
1096
+ })).nullish(),
1097
+ })),
1098
+ }),
1099
+ }),
1100
+ });
1101
+ /**
1102
+ * A standard GraphQL introspection query restricted to OBJECT types + their fields' type references —
1103
+ * everything the selection-set builder + tenant-overlay needs to decide scalar-vs-object.
1104
+ */
1105
+ const INTROSPECTION_QUERY = `query PathLMSIntrospection {
1106
+ __schema {
1107
+ types {
1108
+ kind
1109
+ name
1110
+ fields {
1111
+ name
1112
+ type { kind name ofType { kind name ofType { kind name ofType { kind name } } } }
1113
+ }
1114
+ }
1115
+ }
1116
+ }`;
1117
+ /**
1118
+ * Parses a SpectaQL HTML schema page into the standard universe of record types + fields. Each record type
1119
+ * is a `<section id="definition-<Type>" class="definition definition-object">` block; the non-record SDL
1120
+ * types in {@link NON_RECORD_SDL_TYPES} are removed. Each field row carries the property name + a typed
1121
+ * link `<a href="#definition-<Target>"><code>[Type]!</code></a>` from which the underlying type, list-ness
1122
+ * and non-null are read. This is the credential-free standard-universe enumeration (record TYPES, not the
1123
+ * Query entry-point doors).
1124
+ */
1125
+ export function parseSpectaQLSchema(html) {
1126
+ const starts = [...html.matchAll(/<section id="definition-([A-Za-z0-9_]+)" class="definition definition-object"/g)];
1127
+ const recordTypes = [];
1128
+ for (let i = 0; i < starts.length; i++) {
1129
+ const name = starts[i][1];
1130
+ if (NON_RECORD_SDL_TYPES.has(name))
1131
+ continue;
1132
+ const begin = starts[i].index ?? 0;
1133
+ const end = i + 1 < starts.length ? (starts[i + 1].index ?? html.length) : html.length;
1134
+ const block = html.slice(begin, end);
1135
+ recordTypes.push({
1136
+ Name: name,
1137
+ Description: extractDefinitionDescription(block),
1138
+ Fields: parseFieldRows(block),
1139
+ });
1140
+ }
1141
+ const byName = new Map();
1142
+ for (const rt of recordTypes)
1143
+ byName.set(rt.Name.toLowerCase(), rt);
1144
+ return { RecordTypes: recordTypes, RecordTypesByName: byName };
1145
+ }
1146
+ /** Pulls the top-level field rows out of a definition-object block (skips nested field-argument rows). */
1147
+ function parseFieldRows(block) {
1148
+ // A field row: <td data-property-name="..."><span class="property-name"><code>NAME</code></span>
1149
+ // - <span class="property-type">[<a href="#definition-TARGET">]<code>TYPE</code>...</span> </td> <td>DESC</td>
1150
+ // The trailing description cell is captured so the FK self-alias contradiction check (see
1151
+ // ResolveFKTarget) can read a field's prose — the same signal `scripts/parse-sdl-fk.mjs` used when it
1152
+ // authored the persisted metadata, so the connector's credential-free FK derivation reproduces it.
1153
+ const re = /<td data-property-name="[^"]*"><span class="property-name"><code>([A-Za-z0-9_]+)<\/code><\/span>\s*-\s*<span class="property-type">(?:<a href="#definition-([A-Za-z0-9_]+)">)?<code>([^<]+)<\/code>[\s\S]*?<\/span>\s*<\/td>\s*<td>([\s\S]*?)<\/td>/g;
1154
+ const fields = [];
1155
+ const seen = new Set();
1156
+ let m;
1157
+ while ((m = re.exec(block)) !== null) {
1158
+ const fieldName = m[1];
1159
+ if (seen.has(fieldName))
1160
+ continue; // de-dupe (a field's own row appears once; arg rows don't match this pattern)
1161
+ seen.add(fieldName);
1162
+ const rawType = m[3];
1163
+ const descText = m[4].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
1164
+ fields.push({
1165
+ Name: fieldName,
1166
+ TargetType: m[2] ?? stripTypeWrappers(rawType),
1167
+ IsList: rawType.includes('['),
1168
+ NonNull: /\]?!$/.test(rawType.trim()),
1169
+ Description: descText.length > 0 ? descText : undefined,
1170
+ });
1171
+ }
1172
+ return fields;
1173
+ }
1174
+ /** Reads the prose description for a definition block (the first <p> in its doc-description, if present). */
1175
+ function extractDefinitionDescription(block) {
1176
+ const m = block.match(/<div class="definition-description[^"]*">\s*<p>([\s\S]*?)<\/p>/);
1177
+ if (!m)
1178
+ return undefined;
1179
+ const text = m[1].replace(/<[^>]+>/g, '').trim();
1180
+ return text.length > 0 ? text : undefined;
1181
+ }
1182
+ /** Maps a public-schema SDL named type into the connector's coarse DataType vocabulary. */
1183
+ function sdlTypeToDataType(namedType, isList) {
1184
+ if (isList)
1185
+ return 'json';
1186
+ switch (namedType) {
1187
+ case 'Int': return 'Int';
1188
+ case 'Float': return 'Float';
1189
+ case 'Currency': return 'Float';
1190
+ case 'Boolean': return 'Boolean';
1191
+ case 'Date':
1192
+ case 'DateTime': return 'Date';
1193
+ case 'JSON': return 'json';
1194
+ case 'ID':
1195
+ case 'String':
1196
+ return 'String';
1197
+ case null:
1198
+ return 'String';
1199
+ default:
1200
+ // A named type that is itself an object/enum the field references → opaque json blob.
1201
+ return 'json';
1202
+ }
1203
+ }
1204
+ /** Narrows an unknown value to a plain record. */
1205
+ function isRecord(v) {
1206
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
1207
+ }
1208
+ /** Normalizes a value to a flat array of records: array → its records, object → singleton, else empty. */
1209
+ function toRecordArray(v) {
1210
+ if (Array.isArray(v))
1211
+ return v.filter(isRecord);
1212
+ if (isRecord(v))
1213
+ return [v];
1214
+ return [];
1215
+ }
1216
+ /** Returns the first value of an object (used when no explicit responseDataKey is supplied). */
1217
+ function firstValue(obj) {
1218
+ for (const v of Object.values(obj))
1219
+ return v;
1220
+ return undefined;
1221
+ }
1222
+ /** Formats a GraphQL `errors` array into a single human-readable string. */
1223
+ function formatGraphQLErrors(errors) {
1224
+ if (!Array.isArray(errors))
1225
+ return String(errors);
1226
+ return errors
1227
+ .map(e => (isRecord(e) && typeof e['message'] === 'string' ? e['message'] : JSON.stringify(e)))
1228
+ .join('; ');
1229
+ }
1230
+ /** Strips GraphQL list/non-null wrappers from a type string: `[Foo!]!` → `Foo`. */
1231
+ function stripTypeWrappers(typeStr) {
1232
+ return typeStr.replace(/[[\]!]/g, '').trim();
1233
+ }
1234
+ /**
1235
+ * True when a field's prose marks it a self-alias of the row's own `id` (a renamed view of this record's
1236
+ * identity, NOT a reference to another row). Verbatim the `isSelfAlias` contradiction check in
1237
+ * `scripts/parse-sdl-fk.mjs` that authored the persisted metadata, so the connector's FK derivation
1238
+ * reproduces the metadata's `*Id`-not-an-FK decisions exactly.
1239
+ */
1240
+ function isSelfAliasOfId(description) {
1241
+ if (!description)
1242
+ return false;
1243
+ const d = description.toLowerCase();
1244
+ return /same as (the )?id\b/.test(d) || /alias of (the )?id\b/.test(d) || /for cross.?referencing/.test(d);
1245
+ }
1246
+ /** Parses an operation-argument spec `name:Type` into {Name, Type}. Returns null on malformed input. */
1247
+ function parseArgSpec(spec) {
1248
+ const idx = spec.indexOf(':');
1249
+ if (idx <= 0)
1250
+ return null;
1251
+ const name = spec.slice(0, idx).trim();
1252
+ const type = spec.slice(idx + 1).trim();
1253
+ if (name.length === 0 || type.length === 0)
1254
+ return null;
1255
+ return { Name: name, Type: type };
1256
+ }
1257
+ /** Flattens a GraphQL introspection type ref into {NamedType, IsList, NonNull}. */
1258
+ function flattenIntrospectionType(ref) {
1259
+ let isList = false;
1260
+ let nonNull = false;
1261
+ let cur = ref;
1262
+ let depth = 0;
1263
+ while (cur && depth < 10) {
1264
+ if (cur.kind === 'NON_NULL') {
1265
+ if (depth === 0)
1266
+ nonNull = true;
1267
+ }
1268
+ else if (cur.kind === 'LIST') {
1269
+ isList = true;
1270
+ }
1271
+ else if (cur.name) {
1272
+ return { NamedType: cur.name, IsList: isList, NonNull: nonNull };
1273
+ }
1274
+ cur = cur.ofType;
1275
+ depth++;
1276
+ }
1277
+ return { NamedType: 'Unknown', IsList: isList, NonNull: nonNull };
1278
+ }
1279
+ /** Tree-shaking prevention function — import and call from the module entry point. */
1280
+ export function LoadPathLMSConnector() { }
1281
+ //# sourceMappingURL=PathLMSConnector.js.map