@memberjunction/server 5.42.0 → 5.43.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.
Files changed (38) hide show
  1. package/dist/agentSessions/SessionJanitor.d.ts.map +1 -1
  2. package/dist/agentSessions/SessionJanitor.js +5 -1
  3. package/dist/agentSessions/SessionJanitor.js.map +1 -1
  4. package/dist/agentSessions/SessionManager.d.ts +15 -2
  5. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  6. package/dist/agentSessions/SessionManager.js +50 -11
  7. package/dist/agentSessions/SessionManager.js.map +1 -1
  8. package/dist/generated/generated.d.ts +21 -0
  9. package/dist/generated/generated.d.ts.map +1 -1
  10. package/dist/generated/generated.js +86 -0
  11. package/dist/generated/generated.js.map +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +5 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts +20 -1
  16. package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts.map +1 -1
  17. package/dist/resolvers/ExecuteRemoteOperationResolver.js +61 -5
  18. package/dist/resolvers/ExecuteRemoteOperationResolver.js.map +1 -1
  19. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  20. package/dist/resolvers/IntegrationDiscoveryResolver.js +98 -29
  21. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  22. package/dist/resolvers/RealtimeBridgeResolver.d.ts +9 -0
  23. package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
  24. package/dist/resolvers/RealtimeBridgeResolver.js +50 -3
  25. package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
  26. package/dist/resolvers/RunTemplateResolver.d.ts +8 -0
  27. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  28. package/dist/resolvers/RunTemplateResolver.js +8 -0
  29. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  30. package/package.json +82 -82
  31. package/src/agentSessions/SessionJanitor.ts +15 -1
  32. package/src/agentSessions/SessionManager.ts +59 -10
  33. package/src/generated/generated.ts +65 -0
  34. package/src/index.ts +5 -1
  35. package/src/resolvers/ExecuteRemoteOperationResolver.ts +60 -4
  36. package/src/resolvers/IntegrationDiscoveryResolver.ts +108 -30
  37. package/src/resolvers/RealtimeBridgeResolver.ts +46 -3
  38. package/src/resolvers/RunTemplateResolver.ts +8 -0
@@ -45276,6 +45276,10 @@ export class MJEntityActionInvocation_ {
45276
45276
  @Field()
45277
45277
  _mj__UpdatedAt: Date;
45278
45278
 
45279
+ @Field({nullable: true, description: `Optional class name of a registered runtime-UX driver component (a BaseEntityActionRuntimeUX subclass resolved via MJGlobal.ClassFactory) that owns this invocation's interaction — parameter collection, dry-run preview, confirmation, and progress. NULL invokes the action directly with no custom UX. This lets any action opt into a richer, reusable runtime experience while the grid/toolbar stays operation-agnostic.`})
45280
+ @MaxLength(255)
45281
+ RuntimeUXDriverClass?: string;
45282
+
45279
45283
  @Field()
45280
45284
  @MaxLength(425)
45281
45285
  EntityAction: string;
@@ -45303,6 +45307,9 @@ export class CreateMJEntityActionInvocationInput {
45303
45307
  @Field({ nullable: true })
45304
45308
  Status?: string;
45305
45309
 
45310
+ @Field({ nullable: true })
45311
+ RuntimeUXDriverClass: string | null;
45312
+
45306
45313
  @Field(() => RestoreContextInput, { nullable: true })
45307
45314
  RestoreContext___?: RestoreContextInput;
45308
45315
  }
@@ -45325,6 +45332,9 @@ export class UpdateMJEntityActionInvocationInput {
45325
45332
  @Field({ nullable: true })
45326
45333
  Status?: string;
45327
45334
 
45335
+ @Field({ nullable: true })
45336
+ RuntimeUXDriverClass?: string | null;
45337
+
45328
45338
  @Field(() => [KeyValuePairInput], { nullable: true })
45329
45339
  OldValues___?: KeyValuePairInput[];
45330
45340
 
@@ -60698,6 +60708,10 @@ export class MJOpenApp_ {
60698
60708
  @Field()
60699
60709
  _mj__UpdatedAt: Date;
60700
60710
 
60711
+ @Field({nullable: true, description: `In-repo subdirectory the app was installed from for multi-app repositories (e.g. 'CRM/HubSpot'). NULL when the app's mj-app.json is at the repository root.`})
60712
+ @MaxLength(500)
60713
+ Subpath?: string;
60714
+
60701
60715
  @Field()
60702
60716
  @MaxLength(100)
60703
60717
  InstalledByUser: string;
@@ -60772,6 +60786,9 @@ export class CreateMJOpenAppInput {
60772
60786
  @Field({ nullable: true })
60773
60787
  Status?: string;
60774
60788
 
60789
+ @Field({ nullable: true })
60790
+ Subpath: string | null;
60791
+
60775
60792
  @Field(() => RestoreContextInput, { nullable: true })
60776
60793
  RestoreContext___?: RestoreContextInput;
60777
60794
  }
@@ -60836,6 +60853,9 @@ export class UpdateMJOpenAppInput {
60836
60853
  @Field({ nullable: true })
60837
60854
  Status?: string;
60838
60855
 
60856
+ @Field({ nullable: true })
60857
+ Subpath?: string | null;
60858
+
60839
60859
  @Field(() => [KeyValuePairInput], { nullable: true })
60840
60860
  OldValues___?: KeyValuePairInput[];
60841
60861
 
@@ -62039,6 +62059,9 @@ export class MJProcessRun_ {
62039
62059
  @Field()
62040
62060
  _mj__UpdatedAt: Date;
62041
62061
 
62062
+ @Field(() => Boolean, {description: `When 1, this run was a dry-run (compute-only) preview: the per-record diffs were computed and persisted as Process Run Details, but no changes were written back to the target records. When 0, the run applied its changes.`})
62063
+ DryRun: boolean;
62064
+
62042
62065
  @Field({nullable: true})
62043
62066
  @MaxLength(255)
62044
62067
  RecordProcess?: string;
@@ -62134,6 +62157,9 @@ export class CreateMJProcessRunInput {
62134
62157
  @Field({ nullable: true })
62135
62158
  StartedByUserID: string | null;
62136
62159
 
62160
+ @Field(() => Boolean, { nullable: true })
62161
+ DryRun?: boolean;
62162
+
62137
62163
  @Field(() => RestoreContextInput, { nullable: true })
62138
62164
  RestoreContext___?: RestoreContextInput;
62139
62165
  }
@@ -62213,6 +62239,9 @@ export class UpdateMJProcessRunInput {
62213
62239
  @Field({ nullable: true })
62214
62240
  StartedByUserID?: string | null;
62215
62241
 
62242
+ @Field(() => Boolean, { nullable: true })
62243
+ DryRun?: boolean;
62244
+
62216
62245
  @Field(() => [KeyValuePairInput], { nullable: true })
62217
62246
  OldValues___?: KeyValuePairInput[];
62218
62247
 
@@ -68054,6 +68083,9 @@ export class MJRecordProcess_ {
68054
68083
  @Field()
68055
68084
  _mj__UpdatedAt: Date;
68056
68085
 
68086
+ @Field({nullable: true, description: `JSON configuration for the process's work, used by work types that need structured config beyond Input/Output mappings. For WorkType='FieldRules' this holds the serialized FieldRuleSet (the rules applied to each record). NULL for work types that do not use it.`})
68087
+ Configuration?: string;
68088
+
68057
68089
  @Field({nullable: true})
68058
68090
  @MaxLength(255)
68059
68091
  Category?: string;
@@ -68176,6 +68208,9 @@ export class CreateMJRecordProcessInput {
68176
68208
  @Field(() => Int, { nullable: true })
68177
68209
  MaxConcurrency?: number | null;
68178
68210
 
68211
+ @Field({ nullable: true })
68212
+ Configuration: string | null;
68213
+
68179
68214
  @Field(() => RestoreContextInput, { nullable: true })
68180
68215
  RestoreContext___?: RestoreContextInput;
68181
68216
  }
@@ -68267,6 +68302,9 @@ export class UpdateMJRecordProcessInput {
68267
68302
  @Field(() => Int, { nullable: true })
68268
68303
  MaxConcurrency?: number | null;
68269
68304
 
68305
+ @Field({ nullable: true })
68306
+ Configuration?: string | null;
68307
+
68270
68308
  @Field(() => [KeyValuePairInput], { nullable: true })
68271
68309
  OldValues___?: KeyValuePairInput[];
68272
68310
 
@@ -68670,6 +68708,15 @@ export class MJRemoteOperation_ {
68670
68708
  @Field()
68671
68709
  _mj__UpdatedAt: Date;
68672
68710
 
68711
+ @Field(() => Boolean, {description: `When 1, the AI-generated Code is frozen and Save() will not regenerate it even if Description changes (the Generated-Actions CodeLocked analog). Default 0.`})
68712
+ CodeLocked: boolean;
68713
+
68714
+ @Field({nullable: true, description: `The model's explanation / comments for the AI-generated Code (populated alongside Code when GenerationType=AI). Human-facing review aid.`})
68715
+ CodeComments?: string;
68716
+
68717
+ @Field({nullable: true, description: `JSON array of the libraries the generated body imports: [{ "Library": "@memberjunction/ai-prompts", "ItemsUsed": ["AIPromptRunner"] }, ...]. Bound to the RemoteOperationLibrary JSONType via metadata sync so CodeGen emits a typed LibrariesObject accessor; CodeGen uses it to emit the imports at the top of the generated remote_operations.ts. NULL/empty = only the default always-available libraries are imported.`})
68718
+ Libraries?: string;
68719
+
68673
68720
  @Field({nullable: true})
68674
68721
  @MaxLength(255)
68675
68722
  Category?: string;
@@ -68757,6 +68804,15 @@ export class CreateMJRemoteOperationInput {
68757
68804
  @Field(() => Int, { nullable: true })
68758
68805
  MaxConcurrency: number | null;
68759
68806
 
68807
+ @Field(() => Boolean, { nullable: true })
68808
+ CodeLocked?: boolean;
68809
+
68810
+ @Field({ nullable: true })
68811
+ CodeComments: string | null;
68812
+
68813
+ @Field({ nullable: true })
68814
+ Libraries: string | null;
68815
+
68760
68816
  @Field(() => RestoreContextInput, { nullable: true })
68761
68817
  RestoreContext___?: RestoreContextInput;
68762
68818
  }
@@ -68839,6 +68895,15 @@ export class UpdateMJRemoteOperationInput {
68839
68895
  @Field(() => Int, { nullable: true })
68840
68896
  MaxConcurrency?: number | null;
68841
68897
 
68898
+ @Field(() => Boolean, { nullable: true })
68899
+ CodeLocked?: boolean;
68900
+
68901
+ @Field({ nullable: true })
68902
+ CodeComments?: string | null;
68903
+
68904
+ @Field({ nullable: true })
68905
+ Libraries?: string | null;
68906
+
68842
68907
  @Field(() => [KeyValuePairInput], { nullable: true })
68843
68908
  OldValues___?: KeyValuePairInput[];
68844
68909
 
package/src/index.ts CHANGED
@@ -863,7 +863,11 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
863
863
  onConnect: async (ctx) => {
864
864
  try {
865
865
  const token = String(ctx.connectionParams?.Authorization);
866
- const userPayload = await getUserPayload(token, undefined, dataSources);
866
+ // Carry API keys from connectionParams so API-key / MCP / Node clients can authenticate the socket
867
+ // (validated the same way as the HTTP x-mj-api-key / x-mj-user-api-key headers).
868
+ const systemApiKey = ctx.connectionParams?.['x-mj-api-key'] ? String(ctx.connectionParams['x-mj-api-key']) : undefined;
869
+ const userApiKey = ctx.connectionParams?.['x-mj-user-api-key'] ? String(ctx.connectionParams['x-mj-user-api-key']) : undefined;
870
+ const userPayload = await getUserPayload(token, undefined, dataSources, undefined, systemApiKey, userApiKey);
867
871
 
868
872
  // Store validated payload on the connection for use in context()
869
873
  ctx.extra.userPayload = userPayload;
@@ -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 provider = GetReadWriteProvider(ctx.providers);
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,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
- const entityInfo = md.Entities.find(
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
- const escapedObjectName = obj.SourceObjectName.replace(/'/g, "''");
3492
- const existingMapResult = await new RunView().RunView<MJCompanyIntegrationEntityMapEntity>({
3493
- EntityName: 'MJ: Company Integration Entity Maps',
3494
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND ExternalObjectName='${escapedObjectName}'`,
3495
- OrderBy: '__mj_CreatedAt ASC',
3496
- MaxRows: 1,
3497
- ResultType: 'entity_object',
3498
- BypassCache: true, // idempotency must read COMMITTED state, not a possibly-stale filtered cache
3499
- }, user);
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 (existingMapResult.Success && existingMapResult.Results.length > 0) {
3503
- em = existingMapResult.Results[0]; // reuse — keeps the map stable across re-applies
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
- ): void {
3641
- const envInt = (name: string): number | null => {
3642
- const v = parseInt(process.env[name] ?? '', 10);
3643
- return Number.isFinite(v) && v > 0 ? v : null;
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
- // Delegate the cap decision to the engine's pure, unit-tested decideSchemaLimitViolations.
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: objects.map(o => ({
3650
- Name: o.SourceObjectName,
3651
- ColumnCount: o.Fields?.length ?? fullCountByName.get(o.SourceObjectName.toLowerCase()) ?? 0,
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('; ')}`);
@@ -1,11 +1,11 @@
1
1
  import { Resolver, Mutation, Query, Arg, Ctx, ObjectType, InputType, Field } from 'type-graphql';
2
2
  import { randomUUID } from 'crypto';
3
- import { LogError, UserInfo, IMetadataProvider } from '@memberjunction/core';
3
+ import { LogError, LogStatusEx, UserInfo, IMetadataProvider } from '@memberjunction/core';
4
4
  import { LiveKitTokenService, LiveKitAgentRoomCoordinator, LiveKitEgressService } from '@memberjunction/livekit-room-server';
5
5
  import { AppContext } from '../types.js';
6
6
  import { ResolverBase } from '../generic/ResolverBase.js';
7
7
  import { GetReadWriteProvider } from '../util.js';
8
- import { CreateBridgeRealtimeSession, FinalizeBridgeCoAgentRuns, GetRealtimeModelVoices, CreateBridgeRoomTranscriptSink } from '@memberjunction/ai-agents';
8
+ import { CreateBridgeRealtimeSession, FinalizeBridgeCoAgentRuns, GetRealtimeModelVoices, CreateBridgeRoomTranscriptSink, RealtimeTurnModeratorDecision } from '@memberjunction/ai-agents';
9
9
  import { AIBridgeEngine } from '@memberjunction/ai-bridge-server';
10
10
  import { SessionManager } from '../agentSessions/SessionManager.js';
11
11
  import { NotificationEngine } from '@memberjunction/notifications';
@@ -36,9 +36,25 @@ AIBridgeEngine.Instance.SetSessionRunFinalizer(FinalizeBridgeCoAgentRuns);
36
36
  * "Meeting Room"/scope choices live HERE (the Meet composition layer), keeping the engine generic.
37
37
  */
38
38
  AIBridgeEngine.Instance.SetTranscriptSink(
39
- CreateBridgeRoomTranscriptSink({ ConversationType: 'Meeting Room', ApplicationScope: 'Application' }),
39
+ CreateBridgeRoomTranscriptSink({ ConversationType: 'Meeting Room', ApplicationScope: 'Application', ApplicationName: 'Meet' }),
40
40
  );
41
41
 
42
+ /**
43
+ * Binds the room **turn moderator** onto the bridge engine — **OPT-IN, off by default**. When
44
+ * `MJ_REALTIME_MODERATOR_MODE=on`, a multi-agent room routes each turn through a fast LLM prompt that decides
45
+ * who speaks (see `RealtimeTurnModerator` in `@memberjunction/ai-agents`). By default it's OFF: agents run in
46
+ * plain auto-response, hear everything, and self-moderate (no STT-driven router in the loop) — the
47
+ * coordinator likewise skips meeting mode when the flag is off, so the two stay consistent. We keep the
48
+ * moderator wired-but-toggleable for controlled scenarios (webinars, large rooms, weaker models).
49
+ */
50
+ if (process.env.MJ_REALTIME_MODERATOR_MODE === 'on') {
51
+ AIBridgeEngine.Instance.SetTurnModerator(RealtimeTurnModeratorDecision);
52
+ console.log('[RealtimeBridge] turn MODERATOR mode is ON (MJ_REALTIME_MODERATOR_MODE=on) — multi-agent rooms use the LLM router.');
53
+ } else {
54
+ // Default mode — only surface this at startup when verbose (MJ_VERBOSE) is on; it's the expected state and otherwise just noise.
55
+ LogStatusEx({ message: '[RealtimeBridge] turn moderator mode is OFF (default) — multi-agent rooms run free-for-all: all agents auto-respond + hear everything.', verboseOnly: true });
56
+ }
57
+
42
58
  /**
43
59
  * GraphQL surface for the MJ-native LiveKit room: mints scoped client access tokens and starts an
44
60
  * agent's presence in a room. The thin resolver delegates to `@memberjunction/livekit-room-server`
@@ -319,6 +335,33 @@ export class RealtimeBridgeResolver extends ResolverBase {
319
335
  }
320
336
  }
321
337
 
338
+ /**
339
+ * **Ends the meeting for everyone**: stops EVERY agent bot bridged into a room (by room name, via the
340
+ * coordinator's server-side roster). This is the "End meeting" half of the Zoom-style leave control —
341
+ * usable by any participant, including one who only *joined* the room and never tracked the bridge ids.
342
+ * Returns `true` when the teardown ran (even if the room held zero agents). Best-effort: any error → `false`.
343
+ *
344
+ * @param roomName The LiveKit room to end.
345
+ */
346
+ @Mutation(() => Boolean)
347
+ async EndLiveKitRoom(
348
+ @Arg('roomName', () => String) roomName: string,
349
+ @Ctx() context: AppContext = {} as AppContext,
350
+ ): Promise<boolean> {
351
+ try {
352
+ const user = this.GetUserFromPayload(context.userPayload);
353
+ if (!user) {
354
+ return false;
355
+ }
356
+ const provider = GetReadWriteProvider(context.providers) as unknown as IMetadataProvider;
357
+ await LiveKitAgentRoomCoordinator.Instance.StopAllAgentsInRoom(roomName, 'Explicit', user, provider);
358
+ return true;
359
+ } catch (error) {
360
+ LogError(`EndLiveKitRoom failed: ${error instanceof Error ? error.message : String(error)}`);
361
+ return false;
362
+ }
363
+ }
364
+
322
365
  /**
323
366
  * Lists active Realtime models with the voices each driver supports — the source for the dev model/voice
324
367
  * picker (gated client-side by the `Realtime: Advanced Session Controls` authorization). Read-only; returns
@@ -21,8 +21,16 @@ export class TemplateRunResult {
21
21
  executionTimeMs?: number;
22
22
  }
23
23
 
24
+ /**
25
+ * @deprecated Prefer the `Template.Run` Remote Operation (`@memberjunction/templates-base-types`
26
+ * `TemplateRunOperation`) — one typed call site that routes over the generic `ExecuteRemoteOperation`
27
+ * transport on the client and in-process on the server. This bespoke `RunTemplate` mutation is retained
28
+ * only for backward compatibility with existing external API consumers and may be removed in a future
29
+ * major version.
30
+ */
24
31
  @Resolver()
25
32
  export class RunTemplateResolver extends ResolverBase {
33
+ /** @deprecated Use the `Template.Run` Remote Operation instead. Retained for backcompat. */
26
34
  @Mutation(() => TemplateRunResult)
27
35
  async RunTemplate(
28
36
  @Arg('templateId') templateId: string,