@memberjunction/server 5.42.0 → 5.44.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/agentSessions/SessionJanitor.d.ts.map +1 -1
- package/dist/agentSessions/SessionJanitor.js +5 -1
- package/dist/agentSessions/SessionJanitor.js.map +1 -1
- package/dist/agentSessions/SessionManager.d.ts +15 -2
- package/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +53 -11
- package/dist/agentSessions/SessionManager.js.map +1 -1
- package/dist/agentSessions/index.d.ts.map +1 -1
- package/dist/agentSessions/index.js +3 -1
- package/dist/agentSessions/index.js.map +1 -1
- package/dist/generated/generated.d.ts +1180 -51
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +35564 -29262
- package/dist/generated/generated.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts +20 -1
- package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts.map +1 -1
- package/dist/resolvers/ExecuteRemoteOperationResolver.js +61 -5
- package/dist/resolvers/ExecuteRemoteOperationResolver.js.map +1 -1
- package/dist/resolvers/FileResolver.d.ts +74 -0
- package/dist/resolvers/FileResolver.d.ts.map +1 -1
- package/dist/resolvers/FileResolver.js +211 -2
- package/dist/resolvers/FileResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +98 -29
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts +6 -4
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +29 -14
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/RealtimeBridgeResolver.d.ts +17 -1
- package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeBridgeResolver.js +78 -7
- package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +81 -2
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +293 -17
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js +17 -2
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts +12 -3
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +37 -15
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/RunTemplateResolver.d.ts +8 -0
- package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
- package/dist/resolvers/RunTemplateResolver.js +8 -0
- package/dist/resolvers/RunTemplateResolver.js.map +1 -1
- package/dist/resolvers/meetingRecordingRegistration.d.ts +124 -0
- package/dist/resolvers/meetingRecordingRegistration.d.ts.map +1 -0
- package/dist/resolvers/meetingRecordingRegistration.js +311 -0
- package/dist/resolvers/meetingRecordingRegistration.js.map +1 -0
- package/dist/resolvers/peaksSidecar.d.ts +30 -0
- package/dist/resolvers/peaksSidecar.d.ts.map +1 -0
- package/dist/resolvers/peaksSidecar.js +51 -0
- package/dist/resolvers/peaksSidecar.js.map +1 -0
- package/dist/rest/MediaAccessKeys.d.ts +68 -0
- package/dist/rest/MediaAccessKeys.d.ts.map +1 -0
- package/dist/rest/MediaAccessKeys.js +96 -0
- package/dist/rest/MediaAccessKeys.js.map +1 -0
- package/dist/rest/MediaStreamHandler.d.ts +26 -0
- package/dist/rest/MediaStreamHandler.d.ts.map +1 -0
- package/dist/rest/MediaStreamHandler.js +192 -0
- package/dist/rest/MediaStreamHandler.js.map +1 -0
- package/dist/rest/mediaRange.d.ts +41 -0
- package/dist/rest/mediaRange.d.ts.map +1 -0
- package/dist/rest/mediaRange.js +60 -0
- package/dist/rest/mediaRange.js.map +1 -0
- package/package.json +83 -82
- package/src/__tests__/FileResolverPeaks.test.ts +64 -0
- package/src/__tests__/MediaAccessKeys.test.ts +68 -0
- package/src/__tests__/MediaStreamHandler.test.ts +91 -0
- package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -1
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +69 -0
- package/src/__tests__/RealtimeCoAgentAppAwareness.integration.test.ts +191 -0
- package/src/__tests__/RemoteBrowserSnapshot.test.ts +123 -0
- package/src/__tests__/SessionManager.test.ts +2 -0
- package/src/__tests__/meetingRecordingRegistration.test.ts +248 -0
- package/src/agentSessions/SessionJanitor.ts +15 -1
- package/src/agentSessions/SessionManager.ts +62 -10
- package/src/agentSessions/index.ts +3 -1
- package/src/generated/generated.ts +17659 -13322
- package/src/index.ts +15 -1
- package/src/resolvers/ExecuteRemoteOperationResolver.ts +60 -4
- package/src/resolvers/FileResolver.ts +199 -1
- package/src/resolvers/IntegrationDiscoveryResolver.ts +108 -30
- package/src/resolvers/QueryResolver.ts +33 -19
- package/src/resolvers/RealtimeBridgeResolver.ts +82 -7
- package/src/resolvers/RealtimeClientSessionResolver.ts +326 -9
- package/src/resolvers/RemoteBrowserActionResolver.ts +18 -2
- package/src/resolvers/RunAIAgentResolver.ts +44 -11
- package/src/resolvers/RunTemplateResolver.ts +8 -0
- package/src/resolvers/meetingRecordingRegistration.ts +432 -0
- package/src/resolvers/peaksSidecar.ts +52 -0
- package/src/rest/MediaAccessKeys.ts +121 -0
- package/src/rest/MediaStreamHandler.ts +220 -0
- package/src/rest/mediaRange.ts +76 -0
package/src/index.ts
CHANGED
|
@@ -40,6 +40,7 @@ import createMSSQLConfig from './orm.js';
|
|
|
40
40
|
import { setupRESTEndpoints } from './rest/setupRESTEndpoints.js';
|
|
41
41
|
import { createOAuthCallbackHandler } from './rest/OAuthCallbackHandler.js';
|
|
42
42
|
import { createSignatureWebhookHandler } from './rest/SignatureWebhookHandler.js';
|
|
43
|
+
import { createMediaStreamRouter } from './rest/MediaStreamHandler.js';
|
|
43
44
|
import { createMagicLinkHandler, registerMagicLinkAuthProvider, MAGIC_LINK_MOUNT_PATH } from './auth/magicLink/index.js';
|
|
44
45
|
|
|
45
46
|
import { resolve } from 'node:path';
|
|
@@ -162,6 +163,8 @@ export * from './resolvers/ISAEntityResolver.js';
|
|
|
162
163
|
export * from './resolvers/ArtifactFileResolver.js';
|
|
163
164
|
export * from './resolvers/FileCategoryResolver.js';
|
|
164
165
|
export * from './resolvers/FileResolver.js';
|
|
166
|
+
export * from './rest/MediaAccessKeys.js';
|
|
167
|
+
export * from './rest/MediaStreamHandler.js';
|
|
165
168
|
export * from './resolvers/InfoResolver.js';
|
|
166
169
|
export * from './resolvers/PotentialDuplicateRecordResolver.js';
|
|
167
170
|
export * from './resolvers/RunTestResolver.js';
|
|
@@ -863,7 +866,11 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
863
866
|
onConnect: async (ctx) => {
|
|
864
867
|
try {
|
|
865
868
|
const token = String(ctx.connectionParams?.Authorization);
|
|
866
|
-
|
|
869
|
+
// Carry API keys from connectionParams so API-key / MCP / Node clients can authenticate the socket
|
|
870
|
+
// (validated the same way as the HTTP x-mj-api-key / x-mj-user-api-key headers).
|
|
871
|
+
const systemApiKey = ctx.connectionParams?.['x-mj-api-key'] ? String(ctx.connectionParams['x-mj-api-key']) : undefined;
|
|
872
|
+
const userApiKey = ctx.connectionParams?.['x-mj-user-api-key'] ? String(ctx.connectionParams['x-mj-user-api-key']) : undefined;
|
|
873
|
+
const userPayload = await getUserPayload(token, undefined, dataSources, undefined, systemApiKey, userApiKey);
|
|
867
874
|
|
|
868
875
|
// Store validated payload on the connection for use in context()
|
|
869
876
|
ctx.extra.userPayload = userPayload;
|
|
@@ -987,6 +994,13 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
987
994
|
app.use('/esignature', cors<cors.CorsRequest>(), createSignatureWebhookHandler());
|
|
988
995
|
startupLog.LogIf('verbose', '[eSignature] Webhook route registered at /esignature/webhook/:driverKey');
|
|
989
996
|
|
|
997
|
+
// ─── Authenticated media streaming (token-gated, registered BEFORE auth) ─────
|
|
998
|
+
// The `?token=` query param (minted by CreateMediaAccessToken after a per-user permission
|
|
999
|
+
// check) is the capability — the route verifies it itself, so it does NOT use the MJ bearer
|
|
1000
|
+
// auth middleware (an <audio>/<video> element can't send Authorization headers).
|
|
1001
|
+
app.use('/media', cors<cors.CorsRequest>(), createMediaStreamRouter());
|
|
1002
|
+
startupLog.LogIf('verbose', '[Media] Streaming route registered at /media/:fileId');
|
|
1003
|
+
|
|
990
1004
|
// ─── Magic-link routes (MJ-issued, app-scoped external access) ───────────
|
|
991
1005
|
// Public router (JWKS + redeem) mounts BEFORE the auth middleware; the
|
|
992
1006
|
// authenticated invite-creation router mounts AFTER it (see below).
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import { Arg, Ctx, Field, InputType, Mutation, ObjectType, Resolver } from 'type-graphql';
|
|
1
|
+
import { Arg, Ctx, Field, ID, InputType, Mutation, ObjectType, PubSub, PubSubEngine, Resolver, Root, Subscription } from 'type-graphql';
|
|
2
2
|
import { MJGlobal } from '@memberjunction/global';
|
|
3
|
-
import { BaseRemotableOperation, RemoteOpInvokeMode } from '@memberjunction/core';
|
|
3
|
+
import { BaseRemotableOperation, RemoteOpInvokeMode, RemoteOpProgress } from '@memberjunction/core';
|
|
4
|
+
import { RemoteOperationEngineBase } from '@memberjunction/core-entities';
|
|
4
5
|
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
5
6
|
import { AppContext } from '../types.js';
|
|
6
7
|
import { GetReadWriteProvider } from '../util.js';
|
|
7
8
|
|
|
9
|
+
/** PubSub topic carrying RemoteOpProgress envelopes for attached over-the-wire callers (RO-3). */
|
|
10
|
+
const REMOTE_OP_PROGRESS_TOPIC = 'RemoteOperationProgress';
|
|
11
|
+
|
|
8
12
|
/** Input for the generic Remote Operation transport mutation. */
|
|
9
13
|
@InputType()
|
|
10
14
|
export class ExecuteRemoteOperationInput {
|
|
@@ -16,6 +20,25 @@ export class ExecuteRemoteOperationInput {
|
|
|
16
20
|
|
|
17
21
|
@Field()
|
|
18
22
|
invokeMode: string;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Optional client-generated channel id. When set (attached mode over the wire), the server publishes every
|
|
26
|
+
* `RemoteOpProgress` the operation emits to the `RemoteOperationProgress` subscription filtered by this id,
|
|
27
|
+
* so the caller's `onProgress` fires live while the operation runs. Omitted for sync / no-progress calls.
|
|
28
|
+
*/
|
|
29
|
+
@Field(() => String, { nullable: true })
|
|
30
|
+
progressChannelId?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A single streamed progress envelope delivered over the `RemoteOperationProgress` subscription. */
|
|
34
|
+
@ObjectType()
|
|
35
|
+
export class RemoteOperationProgressNotification {
|
|
36
|
+
@Field()
|
|
37
|
+
ChannelId: string;
|
|
38
|
+
|
|
39
|
+
/** JSON-serialized {@link RemoteOpProgress}. */
|
|
40
|
+
@Field()
|
|
41
|
+
ProgressJSON: string;
|
|
19
42
|
}
|
|
20
43
|
|
|
21
44
|
/** Result of the generic Remote Operation transport mutation. */
|
|
@@ -50,6 +73,7 @@ export class ExecuteRemoteOperationResolver extends ResolverBase {
|
|
|
50
73
|
async ExecuteRemoteOperation(
|
|
51
74
|
@Arg('input') input: ExecuteRemoteOperationInput,
|
|
52
75
|
@Ctx() ctx: AppContext,
|
|
76
|
+
@PubSub() pubSub: PubSubEngine,
|
|
53
77
|
): Promise<ExecuteRemoteOperationResultType> {
|
|
54
78
|
try {
|
|
55
79
|
const key = input.operationKey?.trim();
|
|
@@ -78,6 +102,15 @@ export class ExecuteRemoteOperationResolver extends ResolverBase {
|
|
|
78
102
|
return fail('NO_USER', 'Not authenticated');
|
|
79
103
|
}
|
|
80
104
|
|
|
105
|
+
// (4) Metadata gate (defense-in-depth): honor the op's MJ: Remote Operations Status/approval, so a
|
|
106
|
+
// registered-but-disabled (or unapproved AI) op is rejected even though its class is still in code.
|
|
107
|
+
const provider = GetReadWriteProvider(ctx.providers);
|
|
108
|
+
await RemoteOperationEngineBase.Instance.Config(false, user, provider);
|
|
109
|
+
const invokability = RemoteOperationEngineBase.Instance.IsInvokable(key);
|
|
110
|
+
if (!invokability.Invokable) {
|
|
111
|
+
return fail(invokability.ResultCode ?? 'OPERATION_UNAVAILABLE', invokability.Reason ?? `Operation '${key}' is unavailable`);
|
|
112
|
+
}
|
|
113
|
+
|
|
81
114
|
let parsedInput: unknown;
|
|
82
115
|
try {
|
|
83
116
|
parsedInput = input.inputJSON ? JSON.parse(input.inputJSON) : {};
|
|
@@ -85,9 +118,16 @@ export class ExecuteRemoteOperationResolver extends ResolverBase {
|
|
|
85
118
|
return fail('INVALID_INPUT_JSON', `Invalid input JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
86
119
|
}
|
|
87
120
|
|
|
121
|
+
// Attached over-the-wire (RO-3): forward each emitted RemoteOpProgress to the progress channel so a
|
|
122
|
+
// remote caller's onProgress fires live. No-op when the caller didn't open a channel (sync calls).
|
|
123
|
+
const onProgress = input.progressChannelId
|
|
124
|
+
? (p: RemoteOpProgress) => {
|
|
125
|
+
void pubSub.publish(REMOTE_OP_PROGRESS_TOPIC, { ChannelId: input.progressChannelId!, ProgressJSON: JSON.stringify(p) });
|
|
126
|
+
}
|
|
127
|
+
: undefined;
|
|
128
|
+
|
|
88
129
|
// Route through the per-request provider (server in-process path; runs the op's Authorize + InternalExecute).
|
|
89
|
-
const
|
|
90
|
-
const result = await provider.RouteOperation(key, parsedInput, { user, mode: input.invokeMode as RemoteOpInvokeMode });
|
|
130
|
+
const result = await provider.RouteOperation(key, parsedInput, { user, mode: input.invokeMode as RemoteOpInvokeMode, onProgress });
|
|
91
131
|
|
|
92
132
|
return {
|
|
93
133
|
success: result.Success,
|
|
@@ -100,6 +140,22 @@ export class ExecuteRemoteOperationResolver extends ResolverBase {
|
|
|
100
140
|
return fail('ERROR', e instanceof Error ? e.message : String(e));
|
|
101
141
|
}
|
|
102
142
|
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Subscription the client opens (with a self-generated `channelId`) immediately before calling
|
|
146
|
+
* `ExecuteRemoteOperation` with the same id in `progressChannelId`. The server publishes one event per
|
|
147
|
+
* `RemoteOpProgress` the operation emits; filtered by `channelId` so concurrent calls never interleave.
|
|
148
|
+
*/
|
|
149
|
+
@Subscription(() => RemoteOperationProgressNotification, {
|
|
150
|
+
topics: REMOTE_OP_PROGRESS_TOPIC,
|
|
151
|
+
filter: ({ payload, args }: { payload: RemoteOperationProgressNotification; args: { channelId: string } }) => payload.ChannelId === args.channelId,
|
|
152
|
+
})
|
|
153
|
+
RemoteOperationProgress(
|
|
154
|
+
@Root() notification: RemoteOperationProgressNotification,
|
|
155
|
+
@Arg('channelId', () => ID) _channelId: string,
|
|
156
|
+
): RemoteOperationProgressNotification {
|
|
157
|
+
return notification;
|
|
158
|
+
}
|
|
103
159
|
}
|
|
104
160
|
|
|
105
161
|
/** Builds a failed result. */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { EntityPermissionType, FieldValueCollection, EntitySaveOptions } from '@memberjunction/core';
|
|
1
|
+
import { EntityPermissionType, FieldValueCollection, EntitySaveOptions, LogError, UserInfo } from '@memberjunction/core';
|
|
2
2
|
import { NormalizeUUID } from '@memberjunction/global';
|
|
3
3
|
import { MJFileEntity, MJFileStorageProviderEntity, MJFileStorageAccountEntity } from '@memberjunction/core-entities';
|
|
4
|
+
import { readRealtimeRecordingFile } from '@memberjunction/ai-agents';
|
|
4
5
|
import {
|
|
5
6
|
AppContext,
|
|
6
7
|
Arg,
|
|
@@ -8,6 +9,7 @@ import {
|
|
|
8
9
|
DeleteOptionsInput,
|
|
9
10
|
Field,
|
|
10
11
|
FieldResolver,
|
|
12
|
+
Float,
|
|
11
13
|
InputType,
|
|
12
14
|
Int,
|
|
13
15
|
Mutation,
|
|
@@ -37,6 +39,9 @@ import {
|
|
|
37
39
|
import { CreateMJFileInput, MJFileResolver as FileResolverBase, MJFile_, UpdateMJFileInput } from '../generated/generated.js';
|
|
38
40
|
import { FieldMapper } from '@memberjunction/graphql-dataprovider';
|
|
39
41
|
import { GetReadOnlyProvider } from '../util.js';
|
|
42
|
+
import { configInfo } from '../config.js';
|
|
43
|
+
import { MediaAccessKeyManager } from '../rest/MediaAccessKeys.js';
|
|
44
|
+
import { deriveSidecarPath, parsePeaksSidecar } from './peaksSidecar.js';
|
|
40
45
|
|
|
41
46
|
@InputType()
|
|
42
47
|
export class CreateUploadURLInput {
|
|
@@ -312,8 +317,161 @@ export class SearchAcrossAccountsPayload {
|
|
|
312
317
|
failedAccounts: number;
|
|
313
318
|
}
|
|
314
319
|
|
|
320
|
+
/**
|
|
321
|
+
* Result of {@link FileResolver.GetFileContents} — the bytes of an `MJ: Files` record returned as
|
|
322
|
+
* base64, read server-side through authenticated MJStorage (never a public pre-signed link).
|
|
323
|
+
*/
|
|
324
|
+
@ObjectType()
|
|
325
|
+
export class FileContentsResult {
|
|
326
|
+
@Field(() => Boolean)
|
|
327
|
+
Success: boolean;
|
|
328
|
+
|
|
329
|
+
@Field(() => String, { nullable: true })
|
|
330
|
+
Base64?: string;
|
|
331
|
+
|
|
332
|
+
@Field(() => String, { nullable: true })
|
|
333
|
+
MimeType?: string;
|
|
334
|
+
|
|
335
|
+
@Field(() => String, { nullable: true })
|
|
336
|
+
ErrorMessage?: string;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Result of {@link FileResolver.CreateMediaAccessToken} — a short-lived signed token plus the
|
|
341
|
+
* authenticated streaming URL the browser hands to a `<audio>`/`<video>` element. The token is
|
|
342
|
+
* minted only after a per-user permission check on the `MJ: Files` row; the `/media/:fileId`
|
|
343
|
+
* route re-verifies it (signature + expiry + file match) on each Range request.
|
|
344
|
+
*/
|
|
345
|
+
@ObjectType()
|
|
346
|
+
export class MediaAccessTokenResult {
|
|
347
|
+
@Field(() => Boolean)
|
|
348
|
+
Success: boolean;
|
|
349
|
+
|
|
350
|
+
@Field(() => String, { nullable: true })
|
|
351
|
+
Token?: string;
|
|
352
|
+
|
|
353
|
+
@Field(() => String, { nullable: true })
|
|
354
|
+
Url?: string;
|
|
355
|
+
|
|
356
|
+
@Field(() => Date, { nullable: true })
|
|
357
|
+
ExpiresAt?: Date;
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* The file's MIME type (from the already-loaded `MJ: Files` row). Lets the wrapper choose
|
|
361
|
+
* `<audio>` vs `<video>` WITHOUT re-downloading any bytes; the stream itself sets Content-Type.
|
|
362
|
+
*/
|
|
363
|
+
@Field(() => String, { nullable: true })
|
|
364
|
+
MimeType?: string;
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Optional precomputed waveform peaks (normalized `0..1`, one per rendered bar) read from a
|
|
368
|
+
* `peaks.json` sidecar that sits beside the file in storage. When present, the player renders the
|
|
369
|
+
* real waveform instantly with NO client-side fetch/decode of the audio. Best-effort: a missing or
|
|
370
|
+
* malformed sidecar simply omits this field — it never blocks token minting or fails the mutation.
|
|
371
|
+
*/
|
|
372
|
+
@Field(() => [Float], { nullable: true })
|
|
373
|
+
Peaks?: number[];
|
|
374
|
+
|
|
375
|
+
@Field(() => String, { nullable: true })
|
|
376
|
+
ErrorMessage?: string;
|
|
377
|
+
}
|
|
378
|
+
|
|
315
379
|
@Resolver(MJFile_)
|
|
316
380
|
export class FileResolver extends FileResolverBase {
|
|
381
|
+
/**
|
|
382
|
+
* Resolves the server's public base URL the same way callbacks/magic-link do:
|
|
383
|
+
* `publicUrl` if configured (e.g. an ngrok URL), else `baseUrl:graphqlPort`. Trailing
|
|
384
|
+
* slash stripped so callers can append `/media/...`.
|
|
385
|
+
*/
|
|
386
|
+
private resolvePublicBaseUrl(): string {
|
|
387
|
+
const base = configInfo.publicUrl || `${configInfo.baseUrl}:${configInfo.graphqlPort}${configInfo.graphqlRootPath || ''}`;
|
|
388
|
+
return base.replace(/\/+$/, '');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Mints a short-lived signed media-access token for an `MJ: Files` record and returns the
|
|
393
|
+
* authenticated streaming URL (`<publicBase>/media/<fileId>?token=<token>`). Permission-gated
|
|
394
|
+
* IDENTICALLY to {@link GetFileContents}: the file is loaded under the CALLING USER's context, so
|
|
395
|
+
* MJ row-level security determines access. The returned token is the capability — the streaming
|
|
396
|
+
* route re-verifies it without re-checking row-level access. Never throws to the client.
|
|
397
|
+
*
|
|
398
|
+
* @param fileId The `MJ: Files` id to grant streaming access to.
|
|
399
|
+
* @returns `{ Success, Token?, Url?, ExpiresAt?, ErrorMessage? }`.
|
|
400
|
+
*/
|
|
401
|
+
@Mutation(() => MediaAccessTokenResult)
|
|
402
|
+
async CreateMediaAccessToken(@Arg('fileId', () => String) fileId: string, @Ctx() context: AppContext): Promise<MediaAccessTokenResult> {
|
|
403
|
+
try {
|
|
404
|
+
const provider = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: true });
|
|
405
|
+
const contextUser = this.GetUserFromPayload(context.userPayload);
|
|
406
|
+
|
|
407
|
+
// Permission gate: load the file under the USER's context. A failed load means the file
|
|
408
|
+
// does not exist OR the user lacks read access under MJ row-level security.
|
|
409
|
+
const fileEntity = await provider.GetEntityObject<MJFileEntity>('MJ: Files', contextUser);
|
|
410
|
+
const loaded = await fileEntity.Load(fileId);
|
|
411
|
+
if (!loaded) {
|
|
412
|
+
return { Success: false, ErrorMessage: 'You do not have access to this file or it does not exist.' };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Access authorized — mint the capability token.
|
|
416
|
+
const { Token, ExpiresAt } = MediaAccessKeyManager.Instance.Sign(fileId, contextUser.ID);
|
|
417
|
+
const url = `${this.resolvePublicBaseUrl()}/media/${encodeURIComponent(fileId)}?token=${encodeURIComponent(Token)}`;
|
|
418
|
+
|
|
419
|
+
// Best-effort: surface precomputed waveform peaks from a peaks.json sidecar beside the file, so
|
|
420
|
+
// the player renders the real waveform instantly without fetching/decoding the audio. Never
|
|
421
|
+
// blocks token minting — any failure just omits Peaks.
|
|
422
|
+
const peaks = await this.tryReadPeaksSidecar(fileEntity, contextUser);
|
|
423
|
+
|
|
424
|
+
return { Success: true, Token, Url: url, ExpiresAt, MimeType: fileEntity.ContentType ?? undefined, Peaks: peaks };
|
|
425
|
+
} catch (error) {
|
|
426
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
427
|
+
LogError(`CreateMediaAccessToken failed (file ${fileId}): ${message}`);
|
|
428
|
+
return { Success: false, ErrorMessage: message };
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Best-effort read of a `peaks.json` waveform sidecar that sits in the SAME storage folder as the
|
|
434
|
+
* given file (a JSON array of normalized `0..1` numbers, written at capture time). Derives the
|
|
435
|
+
* folder from the file's `ProviderKey` (strips the final path segment), reads `<folder>/peaks.json`
|
|
436
|
+
* via the file's own storage driver, parses + validates it, and returns sanitized peaks. Returns
|
|
437
|
+
* `undefined` on ANY failure (no ProviderKey, no sidecar, parse error, garbage) — the caller treats
|
|
438
|
+
* peaks as a pure optimization and must never let a sidecar problem affect token minting.
|
|
439
|
+
*
|
|
440
|
+
* @param file The already-loaded (under the user's context) `MJ: Files` row.
|
|
441
|
+
* @param contextUser The calling user — used to resolve the storage driver.
|
|
442
|
+
* @returns Sanitized `0..1` peaks (length-capped), or `undefined`.
|
|
443
|
+
*/
|
|
444
|
+
private async tryReadPeaksSidecar(file: MJFileEntity, contextUser: UserInfo): Promise<number[] | undefined> {
|
|
445
|
+
try {
|
|
446
|
+
// The sidecar lives next to the recording: replace the final path segment with peaks.json.
|
|
447
|
+
const sidecarPath = deriveSidecarPath(file.ProviderKey);
|
|
448
|
+
if (!sidecarPath) {
|
|
449
|
+
return undefined;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Resolve the file's storage account → driver (mirror of readRealtimeRecordingFile's pattern).
|
|
453
|
+
await FileStorageEngine.Instance.Config(false, contextUser);
|
|
454
|
+
let accounts = FileStorageEngine.Instance.GetAccountsByProviderID(file.ProviderID);
|
|
455
|
+
if (accounts.length === 0) {
|
|
456
|
+
await FileStorageEngine.Instance.Config(true, contextUser);
|
|
457
|
+
accounts = FileStorageEngine.Instance.GetAccountsByProviderID(file.ProviderID);
|
|
458
|
+
}
|
|
459
|
+
const account = accounts[0];
|
|
460
|
+
if (!account) {
|
|
461
|
+
return undefined;
|
|
462
|
+
}
|
|
463
|
+
const driver = await FileStorageEngine.Instance.GetDriver(account.ID, contextUser);
|
|
464
|
+
const bytes = await driver.GetObject({ fullPath: sidecarPath });
|
|
465
|
+
if (!bytes || bytes.length === 0) {
|
|
466
|
+
return undefined;
|
|
467
|
+
}
|
|
468
|
+
return parsePeaksSidecar(bytes);
|
|
469
|
+
} catch {
|
|
470
|
+
// No sidecar / unreadable / parse failure — peaks are optional, never surface the error.
|
|
471
|
+
return undefined;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
317
475
|
/**
|
|
318
476
|
* Builds UserContextOptions for storage operations that may require OAuth authentication.
|
|
319
477
|
* This passes the current user's ID and context to allow the storage utilities to
|
|
@@ -431,6 +589,46 @@ export class FileResolver extends FileResolverBase {
|
|
|
431
589
|
return url;
|
|
432
590
|
}
|
|
433
591
|
|
|
592
|
+
/**
|
|
593
|
+
* Returns an `MJ: Files` record's bytes as base64, read server-side through authenticated MJStorage
|
|
594
|
+
* (`GetObject`) — NOT a public pre-signed link. Permission-gated: the file is first loaded under the
|
|
595
|
+
* calling user's context, so MJ row-level security determines access. Never throws to the client.
|
|
596
|
+
*
|
|
597
|
+
* @param fileId The `MJ: Files` id whose bytes to return.
|
|
598
|
+
* @returns `{ Success, Base64?, MimeType?, ErrorMessage? }`.
|
|
599
|
+
*/
|
|
600
|
+
@Query(() => FileContentsResult)
|
|
601
|
+
async GetFileContents(@Arg('fileId', () => String) fileId: string, @Ctx() context: AppContext): Promise<FileContentsResult> {
|
|
602
|
+
try {
|
|
603
|
+
const provider = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: true });
|
|
604
|
+
const contextUser = this.GetUserFromPayload(context.userPayload);
|
|
605
|
+
|
|
606
|
+
// Permission gate FIRST: load the file record under the USER's context. A failed load means the
|
|
607
|
+
// file does not exist OR the user lacks read access under MJ row-level security.
|
|
608
|
+
const fileEntity = await provider.GetEntityObject<MJFileEntity>('MJ: Files', contextUser);
|
|
609
|
+
const loaded = await fileEntity.Load(fileId);
|
|
610
|
+
if (!loaded) {
|
|
611
|
+
return { Success: false, ErrorMessage: 'You do not have access to this file or it does not exist.' };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Read the bytes via authenticated MJStorage (server-side GetObject on the file's own account).
|
|
615
|
+
const result = await readRealtimeRecordingFile(fileId, contextUser, provider);
|
|
616
|
+
if (!result) {
|
|
617
|
+
return { Success: false, ErrorMessage: 'The file could not be read from storage.' };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return {
|
|
621
|
+
Success: true,
|
|
622
|
+
Base64: result.Bytes.toString('base64'),
|
|
623
|
+
MimeType: result.MimeType ?? fileEntity.ContentType ?? undefined,
|
|
624
|
+
};
|
|
625
|
+
} catch (error) {
|
|
626
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
627
|
+
LogError(`GetFileContents failed (file ${fileId}): ${message}`);
|
|
628
|
+
return { Success: false, ErrorMessage: message };
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
434
632
|
@Mutation(() => MJFile_)
|
|
435
633
|
async UpdateFile(@Arg('input', () => UpdateMJFileInput) input: UpdateMJFileInput, @Ctx() context: AppContext, @PubSub() pubSub: PubSubEngine) {
|
|
436
634
|
// if the name is changing, rename the target object as well
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Resolver, Query, Mutation, Arg, Ctx, ObjectType, Field, InputType, Int, Float } from "type-graphql";
|
|
2
|
-
import { CompositeKey, DatabaseProviderBase, LocalCacheManager, Metadata, RunView, UserInfo, LogError, LogStatus, IMetadataProvider, TransactionGroupBase } from "@memberjunction/core";
|
|
2
|
+
import { CompositeKey, DatabaseProviderBase, EntityInfo, LocalCacheManager, Metadata, RunView, UserInfo, LogError, LogStatus, IMetadataProvider, TransactionGroupBase } from "@memberjunction/core";
|
|
3
3
|
import { GetReadOnlyProvider, GetReadWriteProvider } from "../util.js";
|
|
4
4
|
import { CronExpressionHelper } from "@memberjunction/scheduling-engine";
|
|
5
5
|
import {
|
|
@@ -3451,9 +3451,29 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3451
3451
|
): Promise<ApplyAllEntityMapCreated[]> {
|
|
3452
3452
|
const results: ApplyAllEntityMapCreated[] = [];
|
|
3453
3453
|
|
|
3454
|
+
// PERF (large-catalog ApplyAll, e.g. Salesforce 1695 objects): precompute O(1) lookups ONCE before
|
|
3455
|
+
// the per-object loop. The per-object path previously did an O(N) `provider.Entities.find` + a
|
|
3456
|
+
// BypassCache RunView for the existing entity map PER object → O(N²) scans + N DB round-trips, so a
|
|
3457
|
+
// 1695-object connector burned its whole budget in ApplyAll and FullSync never started. One entity
|
|
3458
|
+
// index + one batched entity-map query make both O(1) per object.
|
|
3459
|
+
const entityByKey = new Map(
|
|
3460
|
+
provider.Entities.map(e => [`${e.SchemaName.toLowerCase()}|${e.BaseTable.toLowerCase()}`, e] as const)
|
|
3461
|
+
);
|
|
3462
|
+
const existingMapByExternalName = new Map<string, MJCompanyIntegrationEntityMapEntity>();
|
|
3463
|
+
const allMaps = await new RunView().RunView<MJCompanyIntegrationEntityMapEntity>({
|
|
3464
|
+
EntityName: 'MJ: Company Integration Entity Maps',
|
|
3465
|
+
ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
|
|
3466
|
+
ResultType: 'entity_object',
|
|
3467
|
+
BypassCache: true, // idempotency must read COMMITTED state, not a possibly-stale filtered cache
|
|
3468
|
+
}, user);
|
|
3469
|
+
if (allMaps.Success) {
|
|
3470
|
+
for (const m of allMaps.Results) existingMapByExternalName.set((m.ExternalObjectName ?? '').toLowerCase(), m);
|
|
3471
|
+
}
|
|
3472
|
+
|
|
3454
3473
|
for (const obj of objects) {
|
|
3455
3474
|
const entityMapResult = await this.createSingleEntityMap(
|
|
3456
|
-
companyIntegrationID, obj, connector, companyIntegration, schemaName, user, provider, defaultSyncDirection
|
|
3475
|
+
companyIntegrationID, obj, connector, companyIntegration, schemaName, user, provider, defaultSyncDirection,
|
|
3476
|
+
entityByKey, existingMapByExternalName
|
|
3457
3477
|
);
|
|
3458
3478
|
if (entityMapResult) {
|
|
3459
3479
|
results.push(entityMapResult);
|
|
@@ -3471,10 +3491,14 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3471
3491
|
schemaName: string,
|
|
3472
3492
|
user: UserInfo,
|
|
3473
3493
|
md: IMetadataProvider,
|
|
3474
|
-
defaultSyncDirection: string = 'Pull'
|
|
3494
|
+
defaultSyncDirection: string = 'Pull',
|
|
3495
|
+
entityByKey?: Map<string, EntityInfo>,
|
|
3496
|
+
existingMapByExternalName?: Map<string, MJCompanyIntegrationEntityMapEntity>
|
|
3475
3497
|
): Promise<ApplyAllEntityMapCreated | null> {
|
|
3476
|
-
// Find the entity by schema + table name
|
|
3477
|
-
|
|
3498
|
+
// Find the entity by schema + table name. PERF: O(1) precomputed index when provided
|
|
3499
|
+
// (large-catalog ApplyAll); falls back to the O(N) scan when called without it.
|
|
3500
|
+
const entityInfo = entityByKey?.get(`${schemaName.toLowerCase()}|${obj.TableName.toLowerCase()}`)
|
|
3501
|
+
?? md.Entities.find(
|
|
3478
3502
|
e => e.SchemaName.toLowerCase() === schemaName.toLowerCase()
|
|
3479
3503
|
&& e.BaseTable.toLowerCase() === obj.TableName.toLowerCase()
|
|
3480
3504
|
);
|
|
@@ -3488,19 +3512,27 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3488
3512
|
// multiplies the maps in lockstep (N applies → N duplicate maps per object), which
|
|
3489
3513
|
// silently corrupts the record-map 1:1 completeness gate and makes the forward sync
|
|
3490
3514
|
// process each object N times. Reuse the existing (connection, external object) map instead.
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3515
|
+
// PERF: use the precomputed existing-map index when provided (one batched query for the whole
|
|
3516
|
+
// ApplyAll instead of one BypassCache RunView per object); falls back to a per-object query.
|
|
3517
|
+
let existing: MJCompanyIntegrationEntityMapEntity | undefined;
|
|
3518
|
+
if (existingMapByExternalName) {
|
|
3519
|
+
existing = existingMapByExternalName.get(obj.SourceObjectName.toLowerCase());
|
|
3520
|
+
} else {
|
|
3521
|
+
const escapedObjectName = obj.SourceObjectName.replace(/'/g, "''");
|
|
3522
|
+
const existingMapResult = await new RunView().RunView<MJCompanyIntegrationEntityMapEntity>({
|
|
3523
|
+
EntityName: 'MJ: Company Integration Entity Maps',
|
|
3524
|
+
ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND ExternalObjectName='${escapedObjectName}'`,
|
|
3525
|
+
OrderBy: '__mj_CreatedAt ASC',
|
|
3526
|
+
MaxRows: 1,
|
|
3527
|
+
ResultType: 'entity_object',
|
|
3528
|
+
BypassCache: true, // idempotency must read COMMITTED state, not a possibly-stale filtered cache
|
|
3529
|
+
}, user);
|
|
3530
|
+
if (existingMapResult.Success && existingMapResult.Results.length > 0) existing = existingMapResult.Results[0];
|
|
3531
|
+
}
|
|
3500
3532
|
|
|
3501
3533
|
let em: MJCompanyIntegrationEntityMapEntity;
|
|
3502
|
-
if (
|
|
3503
|
-
em =
|
|
3534
|
+
if (existing) {
|
|
3535
|
+
em = existing; // reuse — keeps the map stable across re-applies
|
|
3504
3536
|
} else {
|
|
3505
3537
|
em = await md.GetEntityObject<MJCompanyIntegrationEntityMapEntity>('MJ: Company Integration Entity Maps', user);
|
|
3506
3538
|
em.NewRecord();
|
|
@@ -3634,26 +3666,70 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3634
3666
|
* still surfaces every object/field; only materialization is capped. Per-table column count = the selected
|
|
3635
3667
|
* field subset, or the object's full discovered field count when all fields are selected.
|
|
3636
3668
|
*/
|
|
3637
|
-
private enforceSchemaLimits(
|
|
3669
|
+
private async enforceSchemaLimits(
|
|
3638
3670
|
objects: SchemaPreviewObjectInput[],
|
|
3639
3671
|
filteredSchema: SourceSchemaInfo,
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3672
|
+
companyIntegration: MJCompanyIntegrationEntity,
|
|
3673
|
+
user: UserInfo,
|
|
3674
|
+
md: IMetadataProvider,
|
|
3675
|
+
): Promise<string[]> {
|
|
3676
|
+
const warnings: string[] = [];
|
|
3645
3677
|
const fullCountByName = new Map(filteredSchema.Objects.map(o => [o.ExternalName.toLowerCase(), o.Fields.length]));
|
|
3646
|
-
//
|
|
3678
|
+
// Use the MAX of the input object's Fields and the filtered-schema count — the batch path can pass
|
|
3679
|
+
// objects with empty Fields (the real columns come from filteredSchema, which is what buildSchema
|
|
3680
|
+
// materializes). `?? ` would wrongly read an empty [] as 0 (0 is not nullish); Math.max is robust to
|
|
3681
|
+
// either source being empty and reflects the count that will actually become columns.
|
|
3682
|
+
const colCount = (o: SchemaPreviewObjectInput): number =>
|
|
3683
|
+
Math.max(fullCountByName.get(o.SourceObjectName.toLowerCase()) ?? 0, o.Fields?.length ?? 0);
|
|
3684
|
+
|
|
3685
|
+
// ── Per-table COLUMN limit: DISABLE-the-offender, NOT reject-the-apply. The limit is clamped at
|
|
3686
|
+
// startup to ≤ IntegrationEngine.MAX_COLUMNS_CEILING (SQL Server's 1024 minus framework column
|
|
3687
|
+
// headroom). An object exceeding it is set Status='Disabled' (reversible — it re-enables on a later
|
|
3688
|
+
// discovery if it has shrunk back under the limit) and dropped from THIS apply, so the materializable
|
|
3689
|
+
// objects still sync instead of the whole apply failing CREATE TABLE. One CLI warning per disabled object.
|
|
3690
|
+
const limit = IntegrationEngine.Instance.MaxColumnsPerTable;
|
|
3691
|
+
const overLimit = objects.filter(o => colCount(o) > limit);
|
|
3692
|
+
if (overLimit.length > 0) {
|
|
3693
|
+
const ioIDByName = new Map(
|
|
3694
|
+
IntegrationEngineBase.Instance.GetActiveIntegrationObjects(companyIntegration.IntegrationID)
|
|
3695
|
+
.map(io => [io.Name.toLowerCase(), io.ID] as [string, string])
|
|
3696
|
+
);
|
|
3697
|
+
for (const o of overLimit) {
|
|
3698
|
+
warnings.push(`Disabled "${o.SourceObjectName}" (${colCount(o)} columns > limit ${limit}) — too wide to materialize as one table; reduce its columns or it stays disabled.`);
|
|
3699
|
+
const ioID = ioIDByName.get(o.SourceObjectName.toLowerCase());
|
|
3700
|
+
if (ioID) {
|
|
3701
|
+
const io = await md.GetEntityObject<MJIntegrationObjectEntity>('MJ: Integration Objects', user);
|
|
3702
|
+
if (await io.Load(ioID)) {
|
|
3703
|
+
io.Status = 'Disabled';
|
|
3704
|
+
if (!await io.Save()) {
|
|
3705
|
+
LogError(`enforceSchemaLimits: failed to disable over-wide IO '${o.SourceObjectName}': ${io.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
3706
|
+
}
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3709
|
+
}
|
|
3710
|
+
// Drop the disabled objects from BOTH the shared objects array (used downstream by
|
|
3711
|
+
// buildTargetConfigs + createEntityAndFieldMaps) and filteredSchema, in place.
|
|
3712
|
+
const disabled = new Set(overLimit.map(o => o.SourceObjectName.toLowerCase()));
|
|
3713
|
+
for (let i = objects.length - 1; i >= 0; i--) {
|
|
3714
|
+
if (disabled.has(objects[i].SourceObjectName.toLowerCase())) objects.splice(i, 1);
|
|
3715
|
+
}
|
|
3716
|
+
filteredSchema.Objects = filteredSchema.Objects.filter(o => !disabled.has(o.ExternalName.toLowerCase()));
|
|
3717
|
+
}
|
|
3718
|
+
|
|
3719
|
+
// MaxTables remains an operator/env REJECT guardrail (unchanged), evaluated on the surviving objects.
|
|
3720
|
+
// Columns are handled above via disable, so the pure decision is asked about tables only here.
|
|
3721
|
+
const maxTables = ((): number | null => {
|
|
3722
|
+
const v = parseInt(process.env.MJ_INTEGRATION_MAX_TABLES ?? '', 10);
|
|
3723
|
+
return Number.isFinite(v) && v > 0 ? v : null;
|
|
3724
|
+
})();
|
|
3647
3725
|
const violations = decideSchemaLimitViolations({
|
|
3648
3726
|
TableCount: objects.length,
|
|
3649
|
-
ColumnCountByTable:
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
})),
|
|
3653
|
-
MaxTables: envInt('MJ_INTEGRATION_MAX_TABLES'),
|
|
3654
|
-
MaxColumnsPerTable: envInt('MJ_INTEGRATION_MAX_COLUMNS_PER_TABLE'),
|
|
3727
|
+
ColumnCountByTable: [],
|
|
3728
|
+
MaxTables: maxTables,
|
|
3729
|
+
MaxColumnsPerTable: null,
|
|
3655
3730
|
});
|
|
3656
3731
|
if (violations.length > 0) throw new Error(violations.join(' '));
|
|
3732
|
+
return warnings;
|
|
3657
3733
|
}
|
|
3658
3734
|
|
|
3659
3735
|
private async buildSchemaForConnector(
|
|
@@ -3717,7 +3793,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3717
3793
|
// §B — reject an over-limit table/column SELECTION before ANY table is materialized. This is the
|
|
3718
3794
|
// single shared gate for ApplyAll / ApplyAllBatch / ApplySchemaBatch (all route through here).
|
|
3719
3795
|
// Caps are operator/env guardrails (MJ_INTEGRATION_MAX_TABLES / _MAX_COLUMNS_PER_TABLE).
|
|
3720
|
-
this.enforceSchemaLimits(objects, filteredSchema);
|
|
3796
|
+
const limitWarnings = await this.enforceSchemaLimits(objects, filteredSchema, companyIntegration, user, provider);
|
|
3721
3797
|
|
|
3722
3798
|
const targetConfigs = this.buildTargetConfigs(objects, filteredSchema, platform, connector);
|
|
3723
3799
|
|
|
@@ -3736,6 +3812,8 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3736
3812
|
|
|
3737
3813
|
const builder = new SchemaBuilder();
|
|
3738
3814
|
const schemaOutput = builder.BuildSchema(input);
|
|
3815
|
+
// Surface the auto-disabled over-wide objects (if any) in the apply's Warnings → CLI output.
|
|
3816
|
+
if (limitWarnings.length > 0) schemaOutput.Warnings.unshift(...limitWarnings);
|
|
3739
3817
|
|
|
3740
3818
|
if (schemaOutput.Errors.length > 0) {
|
|
3741
3819
|
throw new Error(`Schema generation failed: ${schemaOutput.Errors.join('; ')}`);
|