@memberjunction/server 6.2.0-edge.7 → 6.2.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 (86) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/broadcast/BaseCacheInvalidationBroadcastProvider.d.ts.map +1 -1
  3. package/dist/broadcast/BaseCacheInvalidationBroadcastProvider.js +2 -1
  4. package/dist/broadcast/BaseCacheInvalidationBroadcastProvider.js.map +1 -1
  5. package/dist/context.d.ts.map +1 -1
  6. package/dist/context.js +6 -8
  7. package/dist/context.js.map +1 -1
  8. package/dist/generated/graphql-schemas/__mj.d.ts +839 -0
  9. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  10. package/dist/generated/graphql-schemas/__mj.js +14652 -10253
  11. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  12. package/dist/generic/ResolverBase.d.ts.map +1 -1
  13. package/dist/generic/ResolverBase.js +8 -0
  14. package/dist/generic/ResolverBase.js.map +1 -1
  15. package/dist/globalCors.d.ts.map +1 -1
  16. package/dist/globalCors.js +1 -0
  17. package/dist/globalCors.js.map +1 -1
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +44 -8
  21. package/dist/index.js.map +1 -1
  22. package/dist/logging/StartupLogger.d.ts +50 -0
  23. package/dist/logging/StartupLogger.d.ts.map +1 -1
  24. package/dist/logging/StartupLogger.js +15 -22
  25. package/dist/logging/StartupLogger.js.map +1 -1
  26. package/dist/middleware/BaseServerMiddleware.d.ts +21 -3
  27. package/dist/middleware/BaseServerMiddleware.d.ts.map +1 -1
  28. package/dist/middleware/BaseServerMiddleware.js +4 -2
  29. package/dist/middleware/BaseServerMiddleware.js.map +1 -1
  30. package/dist/resolvers/AgentRunStatusPublisher.d.ts +10 -0
  31. package/dist/resolvers/AgentRunStatusPublisher.d.ts.map +1 -1
  32. package/dist/resolvers/AgentRunStatusPublisher.js +18 -2
  33. package/dist/resolvers/AgentRunStatusPublisher.js.map +1 -1
  34. package/dist/resolvers/AutotagPipelineResolver.d.ts +1 -1
  35. package/dist/resolvers/AutotagPipelineResolver.d.ts.map +1 -1
  36. package/dist/resolvers/AutotagPipelineResolver.js +59 -15
  37. package/dist/resolvers/AutotagPipelineResolver.js.map +1 -1
  38. package/dist/resolvers/InfoResolver.js +1 -1
  39. package/dist/resolvers/InfoResolver.js.map +1 -1
  40. package/dist/resolvers/QuerySystemUserResolver.d.ts +2 -1
  41. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  42. package/dist/resolvers/QuerySystemUserResolver.js +12 -6
  43. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  44. package/dist/resolvers/SqlLoggingConfigResolver.d.ts.map +1 -1
  45. package/dist/resolvers/SqlLoggingConfigResolver.js +7 -8
  46. package/dist/resolvers/SqlLoggingConfigResolver.js.map +1 -1
  47. package/dist/resolvers/TagGovernanceResolver.d.ts +12 -0
  48. package/dist/resolvers/TagGovernanceResolver.d.ts.map +1 -1
  49. package/dist/resolvers/TagGovernanceResolver.js +84 -0
  50. package/dist/resolvers/TagGovernanceResolver.js.map +1 -1
  51. package/dist/resolvers/TelemetryResolver.d.ts +13 -8
  52. package/dist/resolvers/TelemetryResolver.d.ts.map +1 -1
  53. package/dist/resolvers/TelemetryResolver.js +40 -9
  54. package/dist/resolvers/TelemetryResolver.js.map +1 -1
  55. package/dist/resolvers/meetings/meetingMutations.d.ts.map +1 -1
  56. package/dist/resolvers/meetings/meetingMutations.js +34 -4
  57. package/dist/resolvers/meetings/meetingMutations.js.map +1 -1
  58. package/dist/util.d.ts.map +1 -1
  59. package/dist/util.js +55 -2
  60. package/dist/util.js.map +1 -1
  61. package/package.json +107 -100
  62. package/src/__tests__/ResolverBase.clientOldValues.test.ts +4 -0
  63. package/src/__tests__/ResolverBase.diagnosticSenderRegistrations.test.ts +26 -0
  64. package/src/__tests__/RunAIAgentResolver.streaming.test.ts +28 -4
  65. package/src/__tests__/TagGovernanceAndAutotagResolvers.test.ts +261 -0
  66. package/src/__tests__/TagGovernanceResolver.test.ts +60 -2
  67. package/src/__tests__/globalCors.test.ts +12 -0
  68. package/src/__tests__/review-capture-valid-token-log.test.ts +98 -0
  69. package/src/__tests__/runStreams.fieldSecurity.test.ts +5 -5
  70. package/src/broadcast/BaseCacheInvalidationBroadcastProvider.ts +2 -1
  71. package/src/context.ts +7 -9
  72. package/src/generated/graphql-schemas/__mj.ts +9449 -6383
  73. package/src/generic/ResolverBase.ts +8 -0
  74. package/src/globalCors.ts +1 -0
  75. package/src/index.ts +45 -9
  76. package/src/logging/StartupLogger.ts +24 -0
  77. package/src/middleware/BaseServerMiddleware.ts +20 -3
  78. package/src/resolvers/AgentRunStatusPublisher.ts +27 -2
  79. package/src/resolvers/AutotagPipelineResolver.ts +57 -13
  80. package/src/resolvers/InfoResolver.ts +1 -1
  81. package/src/resolvers/QuerySystemUserResolver.ts +19 -7
  82. package/src/resolvers/SqlLoggingConfigResolver.ts +7 -9
  83. package/src/resolvers/TagGovernanceResolver.ts +65 -0
  84. package/src/resolvers/TelemetryResolver.ts +44 -9
  85. package/src/resolvers/meetings/meetingMutations.ts +37 -7
  86. package/src/util.ts +55 -3
@@ -67,6 +67,12 @@ import { Subscription } from 'rxjs';
67
67
  * `mj: conversations` and `mj: ai agent sessions` carry the anonymous realtime-session ownership key
68
68
  * (`ExternalID` / `ConversationID`), which their server entities refuse an anonymous visitor to change — a rule
69
69
  * that decides on `Dirty`, so the baseline must be the stored row, not a client-supplied one (#4931).
70
+ *
71
+ * `mj: projects` (conversation folders) is refused a change to `OwnerUserID` or `ParentID` by anyone but the
72
+ * stored owner, a rule that reads the stored owner, so it must hold even if a host stops tracking its changes.
73
+ *
74
+ * `mj: diagnostic sender registrations` is refused a change to its guarded fields by a rule that decides on
75
+ * `Dirty`, so the baseline must be the stored row.
70
76
  */
71
77
  const ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW: ReadonlySet<string> = new Set([
72
78
  'mj: record changes',
@@ -75,6 +81,8 @@ const ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW: ReadonlySet<string> = new Set([
75
81
  'mj: conversations',
76
82
  'mj: ai agent sessions',
77
83
  'mj: ai agent runs',
84
+ 'mj: projects',
85
+ 'mj: diagnostic sender registrations',
78
86
  ]);
79
87
 
80
88
  /** One field's values in a concurrency-conflict diagnostic. */
package/src/globalCors.ts CHANGED
@@ -44,6 +44,7 @@ export function CreateGlobalCorsMiddleware(
44
44
  },
45
45
  credentials: corsWildcard ? false : (settings?.allowCredentials ?? true),
46
46
  maxAge: settings?.maxAge ?? 86400,
47
+ exposedHeaders: ['X-MJ-Trace-ID', 'X-MJ-Diagnostic-Trigger'],
47
48
  }) as RequestHandler;
48
49
  return SkipForServerExtensionRoots(globalCors, extensionOwnedRoots);
49
50
  }
package/src/index.ts CHANGED
@@ -4,8 +4,8 @@ dotenv.config({ quiet: true });
4
4
 
5
5
  import { expressMiddleware } from '@as-integrations/express5';
6
6
  import { mergeSchemas } from '@graphql-tools/schema';
7
- import { Metadata, DatabasePlatform, SetProvider, StartupManager as StartupManagerImport, BaseEntity, BaseEntityEvent, RunView, DatabaseProviderBase, ProviderBase, ResolveStartupMode, UserInfo } from '@memberjunction/core';
8
- import { UserCache, resolveDbPlatformFromEnv } from '@memberjunction/generic-database-provider';
7
+ import { Metadata, DatabasePlatform, SetProvider, StartupManager as StartupManagerImport, BaseEntity, BaseEntityEvent, RunView, DatabaseProviderBase, ProviderBase, ResolveStartupMode, UserInfo, GetDiagnosticContext, IsDiagnosticCaptureSuppressed } from '@memberjunction/core';
8
+ import { UserCache, resolveDbPlatformFromEnv, GenericDatabaseProvider, SummarizeSqlParameters } from '@memberjunction/generic-database-provider';
9
9
  import { MJGlobal, MJEventType, UUIDsEqual, ShutdownRegistry } from '@memberjunction/global';
10
10
  import { setupSQLServerClient, SQLServerDataProvider, SQLServerProviderConfigData } from '@memberjunction/sqlserver-dataprovider';
11
11
  import { extendConnectionPoolWithQuery } from './util.js';
@@ -299,10 +299,11 @@ export * from './multiTenancy/index.js';
299
299
  export * from './middleware/index.js';
300
300
 
301
301
  import { RegisterDataHook } from '@memberjunction/core';
302
+ import { EnsureAnchoredConversationReadHookRegistered } from '@memberjunction/core-entities-server';
302
303
  import type { RequestHandler, ErrorRequestHandler } from 'express';
303
304
  import type { ApolloServerPlugin } from '@apollo/server';
304
305
  import type { GraphQLSchema } from 'graphql';
305
- import { BaseServerMiddleware } from './middleware/BaseServerMiddleware.js';
306
+ import { BaseServerMiddleware, ServerMiddlewareContext } from './middleware/BaseServerMiddleware.js';
306
307
  import { SuppressTaskGraphSubmission } from '@memberjunction/ai-core-plus';
307
308
 
308
309
  export type MJServerOptions = {
@@ -499,7 +500,8 @@ const setupComplete$ = new ReplaySubject(1);
499
500
  await WirePushStatusFanOut(sharedCache);
500
501
  await wireHandoffOfferFanOut(sharedCache, startupLog);
501
502
  await wireRealtimeSessionEventFanOut(sharedCache);
502
- startupLog.LogIf('verbose', `Redis cache provider connected: ${process.env.REDIS_URL}`);
503
+ const safeRedisUrl = (process.env.REDIS_URL ?? '').replace(/:\/\/[^@]+@/, '://***@');
504
+ startupLog.LogIf('verbose', `Redis cache provider connected: ${safeRedisUrl}`);
503
505
  }
504
506
  const cacheManagerConfig = CacheManagerConfigFromSettings(configInfo.cacheSettings);
505
507
  const warmupLeaseMs = WarmupLeaseMsFromSettings(configInfo.cacheSettings);
@@ -596,8 +598,38 @@ const setupComplete$ = new ReplaySubject(1);
596
598
  const thePgPool = poolAny._pgPool as import('pg').Pool;
597
599
  // Translate SQL Server bracket syntax to PostgreSQL double-quote syntax
598
600
  const pgQuery = TranslateBracketsToPG(query);
599
- const result = await thePgPool.query(pgQuery);
600
- return result.rows;
601
+ const startTime = performance.now();
602
+ try {
603
+ const result = await thePgPool.query(pgQuery);
604
+ if (!IsDiagnosticCaptureSuppressed() && GenericDatabaseProvider.HasSqlObservers()) {
605
+ GenericDatabaseProvider.NotifySqlObservers({
606
+ ts: Date.now(),
607
+ sql: pgQuery,
608
+ parameterSummaries: SummarizeSqlParameters(parameters),
609
+ durationMs: performance.now() - startTime,
610
+ rowCount: result.rows?.length ?? 0,
611
+ isMutation: false,
612
+ description: 'ExecuteSQLWithPool(PG)',
613
+ diagnosticContext: GetDiagnosticContext(),
614
+ });
615
+ }
616
+ return result.rows;
617
+ } catch (err) {
618
+ if (!IsDiagnosticCaptureSuppressed() && GenericDatabaseProvider.HasSqlObservers()) {
619
+ GenericDatabaseProvider.NotifySqlObservers({
620
+ ts: Date.now(),
621
+ sql: pgQuery,
622
+ parameterSummaries: SummarizeSqlParameters(parameters),
623
+ durationMs: performance.now() - startTime,
624
+ rowCount: 0,
625
+ isMutation: false,
626
+ errorMessage: err instanceof Error ? err.message : String(err),
627
+ description: 'ExecuteSQLWithPool(PG)',
628
+ diagnosticContext: GetDiagnosticContext(),
629
+ });
630
+ }
631
+ throw err;
632
+ }
601
633
  }
602
634
  return origExecuteSQLWithPool.call(this, pool, query, parameters, contextUser);
603
635
  };
@@ -982,8 +1014,9 @@ const setupComplete$ = new ReplaySubject(1);
982
1014
  }
983
1015
 
984
1016
  // Initialize all middleware
1017
+ const middlewareContext: ServerMiddlewareContext = { ApiRoute: (subpath, options) => ApiRoute(subpath, options) };
985
1018
  for (const mw of middlewares) {
986
- await mw.Initialize();
1019
+ await mw.Initialize(middlewareContext);
987
1020
  startupLog.LogIf('verbose', ` [Middleware] ${mw.Label}`);
988
1021
  }
989
1022
 
@@ -1005,7 +1038,7 @@ const setupComplete$ = new ReplaySubject(1);
1005
1038
 
1006
1039
  // Express app configuration escape hatch
1007
1040
  if (mw.ConfigureExpressApp) {
1008
- await mw.ConfigureExpressApp(app);
1041
+ await mw.ConfigureExpressApp(app, middlewareContext);
1009
1042
  }
1010
1043
 
1011
1044
  // Extract hook methods and register in the global hook store
@@ -1014,6 +1047,7 @@ const setupComplete$ = new ReplaySubject(1);
1014
1047
  RegisterDataHook('PostRunView', mw.PostRunView.bind(mw));
1015
1048
  RegisterDataHook('PreSave', mw.PreSave.bind(mw));
1016
1049
  }
1050
+ EnsureAnchoredConversationReadHookRegistered();
1017
1051
 
1018
1052
  // ─── Resolve middleware-contributed resolver paths and merge into resolvers ───
1019
1053
  let allResolvers = resolvers;
@@ -1606,7 +1640,7 @@ const setupComplete$ = new ReplaySubject(1);
1606
1640
  app.use(
1607
1641
  graphqlRootPath,
1608
1642
  cors<cors.CorsRequest>({
1609
- exposedHeaders: ['X-MJ-Cache-Seq', 'X-MJ-Server-ID'],
1643
+ exposedHeaders: ['X-MJ-Cache-Seq', 'X-MJ-Server-ID', 'X-MJ-Trace-ID', 'X-MJ-Diagnostic-Trigger'],
1610
1644
  }),
1611
1645
  BodyParser.json({ limit: '50mb' }),
1612
1646
  // Express 5 leaves req.body as undefined for non-JSON or empty bodies;
@@ -2447,3 +2481,5 @@ export type { MagicLinkJWTClaims } from './auth/magicLink/index.js';
2447
2481
  export { VerifyHostAssertion } from './realtimeWidget/host-identity.js';
2448
2482
  export type { HostAssertedIdentity, HostAssertionResult } from './realtimeWidget/host-identity.js';
2449
2483
  export { IsScopeLimitedPrincipal } from './auth/scopeLimitedPrincipal.js';
2484
+ export const GetServerStartupSummary = () => StartupLogger.GetLastStartupSnapshot();
2485
+ export type { StartupSummarySnapshot } from './logging/StartupLogger.js';
@@ -82,7 +82,25 @@ type StartupSummaryData = {
82
82
  * logger.PrintSummary();
83
83
  * ```
84
84
  */
85
+ export type StartupSummarySnapshot = {
86
+ Level: ServerLogLevel;
87
+ Summary: Readonly<StartupSummaryData>;
88
+ PhaseTimings: ReadonlyArray<PhaseTiming>;
89
+ TotalStartupMs: number;
90
+ };
91
+
85
92
  export class StartupLogger {
93
+ private static _lastSnapshot: StartupSummarySnapshot | null = null;
94
+
95
+ /**
96
+ * Returns a read-only snapshot of the most recent server startup summary,
97
+ * used by diagnostic health snapshots to report startup phase durations without
98
+ * mutating or re-running startup logic.
99
+ */
100
+ public static GetLastStartupSnapshot(): StartupSummarySnapshot | null {
101
+ return StartupLogger._lastSnapshot;
102
+ }
103
+
86
104
  private readonly resolvedLevel: ServerLogLevel;
87
105
  private readonly phaseTimings: PhaseTiming[] = [];
88
106
  private readonly summary: StartupSummaryData = { AuthProviders: [] };
@@ -302,6 +320,12 @@ export class StartupLogger {
302
320
  // Clear the transient spinner before any block output so the line it
303
321
  // occupied is replaced cleanly by the summary.
304
322
  this.StopBoot();
323
+ StartupLogger._lastSnapshot = {
324
+ Level: this.resolvedLevel,
325
+ Summary: { ...this.summary, AuthProviders: [...this.summary.AuthProviders] },
326
+ PhaseTimings: this.phaseTimings.map((p) => ({ ...p })),
327
+ TotalStartupMs: performance.now(),
328
+ };
305
329
  if (this.resolvedLevel === 'minimal') {
306
330
  this.printReadyLineOnly();
307
331
  return;
@@ -18,6 +18,20 @@ import type { ApolloServerPlugin } from '@apollo/server';
18
18
  import type { GraphQLSchema } from 'graphql';
19
19
  import type { RunViewParams, UserInfo, BaseEntity, RunViewResult } from '@memberjunction/core';
20
20
 
21
+ /**
22
+ * What the server hands each middleware at startup.
23
+ */
24
+ export interface ServerMiddlewareContext {
25
+ /**
26
+ * Builds the path of a route under MJAPI's base path (`MJAPI_BASE_PATH`).
27
+ * `subpath` must start with `/`. Use it for every route a middleware registers or compares against.
28
+ * With `{ absolute: true }` it returns the public URL of the route (`MJAPI_PUBLIC_URL` + subpath).
29
+ * The options type is written out here, not imported from `apiRoot.ts`: packages that read this
30
+ * file's types from source must not compile `config.ts`.
31
+ */
32
+ ApiRoute: (subpath: string, options?: { absolute?: boolean }) => string;
33
+ }
34
+
21
35
  export abstract class BaseServerMiddleware {
22
36
  // --- Identity ---
23
37
 
@@ -37,9 +51,11 @@ export abstract class BaseServerMiddleware {
37
51
 
38
52
  /**
39
53
  * Optional async initialization (read config, warm caches, etc.).
40
- * Called by serve() after instantiation, before middleware/hooks are extracted.
54
+ * Called by serve() after instantiation, before middleware/hooks are extracted,
55
+ * so a context stored here is available to every other hook.
56
+ * @param context - The server's route builder. serve() always passes it.
41
57
  */
42
- async Initialize(): Promise<void> { /* no-op by default */ }
58
+ async Initialize(context?: ServerMiddlewareContext): Promise<void> { /* no-op by default */ }
43
59
 
44
60
  // --- Express Middleware ---
45
61
  // Each method corresponds to a named slot in the request pipeline.
@@ -73,8 +89,9 @@ export abstract class BaseServerMiddleware {
73
89
  * Called once during server setup with the Express app instance.
74
90
  * Use for: trust proxy settings, custom CORS, body parser config.
75
91
  * Return undefined to skip (default).
92
+ * @param context - The server's route builder. serve() always passes it.
76
93
  */
77
- ConfigureExpressApp?(app: Application): void | Promise<void>;
94
+ ConfigureExpressApp?(app: Application, context?: ServerMiddlewareContext): void | Promise<void>;
78
95
 
79
96
  // --- Apollo / GraphQL Extensions ---
80
97
 
@@ -17,13 +17,18 @@ import type {
17
17
  import type { EntityInfo } from '@memberjunction/core';
18
18
  import type { UserPayload } from '../types.js';
19
19
  import { PublishStatusUpdate, StatusUpdateOwnerID } from '../generic/PushStatusResolver.js';
20
- import { ProjectEntityObjectForCaller } from '../generic/fieldSecurityTransport.js';
20
+ import { ProjectEntityObjectForCaller, ProjectValuesForCaller } from '../generic/fieldSecurityTransport.js';
21
21
 
22
22
  const SIGNIFICANT_STEPS = ['prompt_execution', 'action_execution', 'subagent_execution', 'decision_processing'];
23
23
 
24
24
  /** The run fields the published messages read. Entities and test fakes both satisfy this. */
25
25
  interface StatusRun {
26
26
  ID?: string;
27
+ AgentID?: string;
28
+ Agent?: string | null;
29
+ ParentRunID?: string | null;
30
+ Status?: string;
31
+ ConversationID?: string | null;
27
32
  ConversationDetailID?: string;
28
33
  ErrorMessage?: string;
29
34
  Steps?: Array<{ StepName?: string }>;
@@ -31,6 +36,9 @@ interface StatusRun {
31
36
  GetAll?: () => Record<string, unknown>;
32
37
  }
33
38
 
39
+ /** The run fields a streamed chunk carries. */
40
+ type RunSummary = Pick<StatusRun, 'ID' | 'AgentID' | 'Agent' | 'ParentRunID' | 'Status' | 'ConversationID' | 'ConversationDetailID'>;
41
+
34
42
  export class AgentRunStatusPublisher {
35
43
  private run: StatusRun | null = null;
36
44
 
@@ -102,7 +110,7 @@ export class AgentRunStatusPublisher {
102
110
  agentRunId: agentRun.ID,
103
111
  type: 'streaming',
104
112
  ...this.replyRow(),
105
- agentRun: this.runValues(agentRun),
113
+ agentRun: this.runSummary(agentRun),
106
114
  streaming: {
107
115
  content: chunk.content,
108
116
  isPartial: !chunk.isComplete,
@@ -188,6 +196,23 @@ export class AgentRunStatusPublisher {
188
196
  return ProjectEntityObjectForCaller({ EntityInfo: agentRun.EntityInfo, GetAll: () => getAll.call(agentRun) }, this.userPayload?.userRecord);
189
197
  }
190
198
 
199
+ /**
200
+ * The run's identifying fields as the recipient may read them. A streamed chunk carries only
201
+ * these: the run's `GetAll()` holds every step and prompt run, and a run streams many chunks.
202
+ */
203
+ private runSummary(agentRun: StatusRun): RunSummary {
204
+ const summary: RunSummary = {
205
+ ID: agentRun.ID,
206
+ AgentID: agentRun.AgentID,
207
+ Agent: agentRun.Agent,
208
+ ParentRunID: agentRun.ParentRunID,
209
+ Status: agentRun.Status,
210
+ ConversationID: agentRun.ConversationID,
211
+ ConversationDetailID: agentRun.ConversationDetailID,
212
+ };
213
+ return ProjectValuesForCaller(agentRun.EntityInfo, summary, this.userPayload?.userRecord);
214
+ }
215
+
191
216
  private publish(type: 'ExecutionProgress' | 'StreamingContent', data: unknown): void {
192
217
  PublishStatusUpdate(this.pubSub, {
193
218
  sessionId: this.userPayload.sessionId,
@@ -1,6 +1,7 @@
1
1
  import { Resolver, Mutation, Ctx, Arg, ObjectType, Field } from 'type-graphql';
2
2
  import { AppContext } from '../types.js';
3
3
  import { IMetadataProvider, LogError, LogStatus, Metadata, RunView, UserInfo } from '@memberjunction/core';
4
+ import { IsValidUUID, EscapeSQLString } from '@memberjunction/global';
4
5
  import { MJContentProcessRunEntity } from '@memberjunction/core-entities';
5
6
  import { ResolverBase } from '../generic/ResolverBase.js';
6
7
  import { ActionEngineServer } from '@memberjunction/actions';
@@ -32,6 +33,7 @@ export class AutotagPipelineResolver extends ResolverBase {
32
33
  async RunAutotagPipeline(
33
34
  @Arg('contentSourceIDs', () => [String], { nullable: true }) contentSourceIDs: string[] | undefined,
34
35
  @Arg('forceReprocess', { nullable: true }) forceReprocess: boolean | undefined,
36
+ @Arg('pipelineRunID', { nullable: true }) clientPipelineRunID: string | undefined,
35
37
  @Ctx() { userPayload, providers }: AppContext = {} as AppContext
36
38
  ): Promise<AutotagPipelineResult> {
37
39
  try {
@@ -40,14 +42,37 @@ export class AutotagPipelineResolver extends ResolverBase {
40
42
  return { Success: false, Status: 'Error', ErrorMessage: 'Unable to determine current user' };
41
43
  }
42
44
 
43
- const pipelineRunID = uuidv4();
44
- LogStatus(`RunAutotagPipeline: starting pipeline ${pipelineRunID}`);
45
-
46
45
  // Capture the per-request provider snapshot before returning so the fire-and-forget
47
46
  // background job binds to the same connection the caller used. Without this, the
48
47
  // background job would silently use the global default — wrong for multi-tenant.
49
- const provider = (GetReadWriteProvider(providers, { allowFallbackToReadOnly: true }) as unknown as IMetadataProvider)
50
- ?? (new Metadata() as unknown as IMetadataProvider);
48
+ const provider: IMetadataProvider = GetReadWriteProvider(providers, { allowFallbackToReadOnly: true }) ?? Metadata.Provider;
49
+
50
+ let pipelineRunID: string;
51
+ if (clientPipelineRunID != null) {
52
+ if (!IsValidUUID(clientPipelineRunID)) {
53
+ return { Success: false, Status: 'Error', ErrorMessage: 'Invalid pipelineRunID format' };
54
+ }
55
+ const rv = RunView.FromMetadataProvider(provider);
56
+ const existing = await rv.RunView<{ ID: string }>({
57
+ EntityName: 'MJ: Content Process Runs',
58
+ ExtraFilter: `ID='${EscapeSQLString(clientPipelineRunID)}'`,
59
+ Fields: ['ID'],
60
+ ResultType: 'simple',
61
+ MaxRows: 1,
62
+ SkipTotalRowCount: true,
63
+ }, currentUser);
64
+ if (!existing.Success) {
65
+ return { Success: false, Status: 'Error', ErrorMessage: existing.ErrorMessage || 'Failed to validate pipelineRunID' };
66
+ }
67
+ if (existing.Results.length > 0) {
68
+ return { Success: false, Status: 'Error', ErrorMessage: `Pipeline run ${clientPipelineRunID} already exists` };
69
+ }
70
+ pipelineRunID = clientPipelineRunID;
71
+ } else {
72
+ pipelineRunID = uuidv4();
73
+ }
74
+
75
+ LogStatus(`RunAutotagPipeline: starting pipeline ${pipelineRunID}`);
51
76
 
52
77
  // Fire-and-forget: start the pipeline in the background and return immediately
53
78
  this.runPipelineInBackground(pipelineRunID, currentUser, provider, contentSourceIDs, forceReprocess);
@@ -101,9 +126,19 @@ export class AutotagPipelineResolver extends ResolverBase {
101
126
  // and keeps the process run record in sync
102
127
  this.publishProgress(pipelineRunID, 'autotag', 0, 0, startTime, 'Running autotaggers...');
103
128
 
129
+ let lastTotal = 0;
130
+ let lastProcessed = 0;
131
+ let lastLabel = 'Scanning & extracting content from sources...';
132
+
104
133
  const progressCallback = (processed: number, total: number, currentItem?: string) => {
105
- const pct = total > 0 ? Math.round((processed / total) * 80) : 0; // 0-80% for tagging
106
- this.publishProgress(pipelineRunID, 'autotag', total, pct, startTime, currentItem || `${processed}/${total} items`);
134
+ lastProcessed = processed;
135
+ lastTotal = total;
136
+ if (currentItem) {
137
+ lastLabel = currentItem;
138
+ } else if (total > 0) {
139
+ lastLabel = `${processed}/${total} items`;
140
+ }
141
+ this.publishProgress(pipelineRunID, 'autotag', total, processed, startTime, lastLabel);
107
142
  this.updateProcessRunProgress(processRun, processed, total);
108
143
  };
109
144
 
@@ -121,12 +156,21 @@ export class AutotagPipelineResolver extends ResolverBase {
121
156
  actionParams.push({ Name: 'ForceReprocess', Value: 1, Type: 'Input' });
122
157
  }
123
158
 
124
- const result = await ActionEngineServer.Instance.RunAction({
125
- Action: action,
126
- ContextUser: currentUser,
127
- Filters: [],
128
- Params: actionParams
129
- });
159
+ const heartbeatTimer = setInterval(() => {
160
+ this.publishProgress(pipelineRunID, 'autotag', lastTotal, lastProcessed, startTime, lastLabel);
161
+ }, 3000);
162
+
163
+ let result;
164
+ try {
165
+ result = await ActionEngineServer.Instance.RunAction({
166
+ Action: action,
167
+ ContextUser: currentUser,
168
+ Filters: [],
169
+ Params: actionParams
170
+ });
171
+ } finally {
172
+ clearInterval(heartbeatTimer);
173
+ }
130
174
 
131
175
  // Stage: vectorize complete
132
176
  this.publishProgress(pipelineRunID, 'vectorize', 100, 90, startTime, 'Vectorizing content...');
@@ -42,7 +42,7 @@ export class InfoResolver {
42
42
  Version: packageJson.version,
43
43
  IsSystemUser: Boolean(context.userPayload.isSystemUser),
44
44
  Platform: os.platform(),
45
- Arch: os.platform(),
45
+ Arch: os.arch(),
46
46
  CpuModel: os.cpus()?.[0].model,
47
47
  Hostname: os.hostname(),
48
48
  };
@@ -382,14 +382,15 @@ export class MJQueryResolverExtended extends MJQueryResolver {
382
382
  /** After a successful create: the AfterCreate event, the permissions, and the result. */
383
383
  private async completeCreateAsync(provider: DatabaseProviderBase, input: CreateQuerySystemUserInput, record: MJQueryEntityServer, user: UserInfo): Promise<QueryMutationResultType> {
384
384
  await this.AfterCreate(provider, input); // fire event
385
+ let createdPermissions: MJQueryPermissionEntity[] | undefined;
385
386
  if (input.Permissions && input.Permissions.length > 0) {
386
387
  try {
387
- await this.createPermissions(provider, input.Permissions, record.ID, user);
388
+ createdPermissions = await this.createPermissions(provider, input.Permissions, record.ID, user);
388
389
  } catch (e) {
389
390
  return { Success: false, ErrorMessage: `Query '${record.Name}' (ID ${record.ID}) was created, but its permissions were not: ${e instanceof Error ? e.message : String(e)}` };
390
391
  }
391
392
  }
392
- return this.buildSuccessResult(record, user, provider);
393
+ return this.buildSuccessResult(record, user, provider, undefined, createdPermissions);
393
394
  }
394
395
 
395
396
  /** The create-or-get result: the query another request created with this name. */
@@ -415,7 +416,14 @@ export class MJQueryResolverExtended extends MJQueryResolver {
415
416
  *
416
417
  * @param contextUser The caller the result is for. Field security projects the record for them.
417
418
  */
418
- private async buildSuccessResult(entity: MJQueryEntityServer, contextUser: UserInfo, provider: DatabaseProviderBase, children?: StoredQueryChildren): Promise<QueryMutationResultType> {
419
+ private async buildSuccessResult(
420
+ entity: MJQueryEntityServer,
421
+ contextUser: UserInfo,
422
+ provider: DatabaseProviderBase,
423
+ children?: StoredQueryChildren,
424
+ createdPermissions?: MJQueryPermissionEntity[]
425
+ ): Promise<QueryMutationResultType> {
426
+ const permissionsToMap = children?.Permissions ?? createdPermissions ?? entity.QueryPermissions;
419
427
  return {
420
428
  Success: true,
421
429
  // Mapped for the caller, so the field-security projection applies: the record was just
@@ -425,7 +433,7 @@ export class MJQueryResolverExtended extends MJQueryResolver {
425
433
  Fields: this.mapFields(children?.Fields ?? entity.QueryFields),
426
434
  Parameters: this.mapParameters(children?.Parameters ?? entity.QueryParameters),
427
435
  Entities: this.mapEntities(children?.Entities ?? entity.QueryEntities),
428
- Permissions: this.mapPermissions(children?.Permissions ?? entity.QueryPermissions),
436
+ Permissions: this.mapPermissions(permissionsToMap),
429
437
  };
430
438
  }
431
439
 
@@ -532,7 +540,8 @@ export class MJQueryResolverExtended extends MJQueryResolver {
532
540
  *
533
541
  * @throws Error naming the role and the save's own message when a row is not saved
534
542
  */
535
- protected async createPermissions(p: DatabaseProviderBase, permissions: QueryPermissionInputType[], queryID: string, contextUser: UserInfo): Promise<void> {
543
+ protected async createPermissions(p: DatabaseProviderBase, permissions: QueryPermissionInputType[], queryID: string, contextUser: UserInfo): Promise<MJQueryPermissionEntity[]> {
544
+ const created: MJQueryPermissionEntity[] = [];
536
545
  for (const perm of permissions) {
537
546
  const permissionEntity = await p.GetEntityObject<MJQueryPermissionEntity>('MJ: Query Permissions', contextUser);
538
547
  if (permissionEntity) {
@@ -541,8 +550,10 @@ export class MJQueryResolverExtended extends MJQueryResolver {
541
550
  if (!await permissionEntity.Save()) {
542
551
  throw new Error(MJQueryResolverExtended.permissionWriteError('save', perm.RoleID, permissionEntity));
543
552
  }
553
+ created.push(permissionEntity);
544
554
  }
545
555
  }
556
+ return created;
546
557
  }
547
558
 
548
559
  /**
@@ -648,17 +659,18 @@ export class MJQueryResolverExtended extends MJQueryResolver {
648
659
  const queryID = queryEntity.ID;
649
660
 
650
661
  // Handle permissions update if provided
662
+ let createdPermissions: MJQueryPermissionEntity[] | undefined;
651
663
  if (input.Permissions !== undefined) {
652
664
  // Replace the existing permissions (read from the QueryEngine cache); a failed write stops the update with its reason
653
665
  try {
654
666
  await this.deletePermissions(queryID);
655
- await this.createPermissions(provider, input.Permissions, queryID, context.userPayload.userRecord);
667
+ createdPermissions = await this.createPermissions(provider, input.Permissions, queryID, context.userPayload.userRecord);
656
668
  } catch (e) {
657
669
  return { Success: false, ErrorMessage: `Failed to update query permissions: ${e instanceof Error ? e.message : String(e)}` };
658
670
  }
659
671
  }
660
672
 
661
- return await this.buildSuccessResult(queryEntity, context.userPayload.userRecord, provider);
673
+ return await this.buildSuccessResult(queryEntity, context.userPayload.userRecord, provider, undefined, createdPermissions);
662
674
 
663
675
  } catch (err) {
664
676
  LogError(err);
@@ -9,6 +9,7 @@ import { LoadConfig } from '../config.js';
9
9
  import { ResolverBase } from '../generic/ResolverBase.js';
10
10
  import { GetReadOnlyProvider } from '../util.js';
11
11
  import { UserCache, SqlLoggingOptions as ProviderSqlLoggingOptions } from '@memberjunction/generic-database-provider';
12
+ import { IsScopeLimitedPrincipal } from '../auth/scopeLimitedPrincipal.js';
12
13
 
13
14
  /**
14
15
  * Configuration options for SQL logging sessions.
@@ -252,6 +253,9 @@ export class SqlLoggingConfigResolver extends ResolverBase {
252
253
  * @private
253
254
  */
254
255
  private async checkOwnerAccess(context: AppContext): Promise<UserInfo> {
256
+ if (context?.userPayload?.userRecord && IsScopeLimitedPrincipal(context.userPayload.userRecord)) {
257
+ throw new Error('Access denied. Scope-limited principals cannot access SQL logging.');
258
+ }
255
259
  const userEmail = context.userPayload?.email;
256
260
  if (!userEmail) {
257
261
  throw new Error('User not authenticated');
@@ -263,15 +267,9 @@ export class SqlLoggingConfigResolver extends ResolverBase {
263
267
  if (!user) {
264
268
  throw new Error('User not found');
265
269
  }
266
-
267
- // Debug logging
268
- console.log('SQL Logging access check:', {
269
- email: user.Email,
270
- type: user.Type,
271
- typeLength: user.Type?.length,
272
- typeTrimmed: user.Type?.trim(),
273
- isOwner: user.Type?.trim().toLowerCase() === 'owner'
274
- });
270
+ if (IsScopeLimitedPrincipal(user)) {
271
+ throw new Error('Access denied. Scope-limited principals cannot access SQL logging.');
272
+ }
275
273
 
276
274
  // Check if user has Type = 'Owner' (trim and case-insensitive for nchar fields)
277
275
  if (user.Type?.trim().toLowerCase() !== 'owner') {
@@ -3,6 +3,7 @@ import { AppContext } from '../types.js';
3
3
  import { LogError, LogStatus } from '@memberjunction/core';
4
4
  import { ResolverBase } from '../generic/ResolverBase.js';
5
5
  import { TagGovernanceEngine, TagHealthJob, DEFAULT_TAG_HEALTH_THRESHOLDS, TagEngine } from '@memberjunction/tag-engine';
6
+ import { TagCoOccurrenceEngine } from '@memberjunction/tag-engine-base';
6
7
 
7
8
  @ObjectType()
8
9
  export class PromoteSuggestionResult {
@@ -28,6 +29,30 @@ export class RejectSuggestionResult {
28
29
  ErrorMessage?: string;
29
30
  }
30
31
 
32
+ @ObjectType()
33
+ export class MergeTagsResult {
34
+ @Field()
35
+ Success: boolean;
36
+
37
+ @Field(() => Int)
38
+ ItemsMoved: number;
39
+
40
+ @Field(() => Int)
41
+ TaggedItemsMoved: number;
42
+
43
+ @Field({ nullable: true })
44
+ ErrorMessage?: string;
45
+ }
46
+
47
+ @ObjectType()
48
+ export class RecomputeTagCoOccurrenceResult {
49
+ @Field(() => Int)
50
+ PairsUpdated: number;
51
+
52
+ @Field(() => Int)
53
+ PairsDeleted: number;
54
+ }
55
+
31
56
  @ObjectType()
32
57
  export class RebuildTagEmbeddingsResult {
33
58
  @Field()
@@ -186,4 +211,44 @@ export class TagGovernanceResolver extends ResolverBase {
186
211
  return { Success: false, MergeCount: 0, LowUsageCount: 0, WideNodeCount: 0, DurationMs: 0, ErrorMessage: msg };
187
212
  }
188
213
  }
214
+
215
+ @Mutation(() => MergeTagsResult)
216
+ async MergeTags(
217
+ @Arg('sourceTagIDs', () => [String]) sourceTagIDs: string[],
218
+ @Arg('survivingTagID') survivingTagID: string,
219
+ @Ctx() { userPayload }: AppContext = {} as AppContext
220
+ ): Promise<MergeTagsResult> {
221
+ try {
222
+ const currentUser = this.GetUserFromPayload(userPayload);
223
+ if (!currentUser) {
224
+ return { Success: false, ItemsMoved: 0, TaggedItemsMoved: 0, ErrorMessage: 'Unable to determine current user' };
225
+ }
226
+ await TagEngine.Instance.Config(false, currentUser);
227
+ const result = await TagGovernanceEngine.Instance.MergeTags(sourceTagIDs, survivingTagID, currentUser);
228
+ return {
229
+ Success: true,
230
+ ItemsMoved: result.ItemsMoved,
231
+ TaggedItemsMoved: result.TaggedItemsMoved,
232
+ };
233
+ } catch (error) {
234
+ const msg = error instanceof Error ? error.message : String(error);
235
+ LogError(`MergeTags failed for survivingTagID ${survivingTagID}: ${msg}`);
236
+ return { Success: false, ItemsMoved: 0, TaggedItemsMoved: 0, ErrorMessage: msg };
237
+ }
238
+ }
239
+
240
+ @Mutation(() => RecomputeTagCoOccurrenceResult)
241
+ async RecomputeTagCoOccurrence(
242
+ @Ctx() { userPayload }: AppContext = {} as AppContext
243
+ ): Promise<RecomputeTagCoOccurrenceResult> {
244
+ const currentUser = this.GetUserFromPayload(userPayload);
245
+ if (!currentUser) {
246
+ throw new Error('Unable to determine current user');
247
+ }
248
+ const result = await TagCoOccurrenceEngine.Instance.RecomputeCoOccurrence(currentUser);
249
+ return {
250
+ PairsUpdated: result.PairsUpdated,
251
+ PairsDeleted: result.PairsDeleted,
252
+ };
253
+ }
189
254
  }