@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.
- package/dist/PathLMSConnector.d.ts +394 -0
- package/dist/PathLMSConnector.js +1281 -0
- package/dist/PathLMSConnector.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/package.json +42 -0
|
@@ -0,0 +1,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
|