@memberjunction/server 6.1.3 → 6.1.5

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 (93) hide show
  1. package/README.md +30 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/auth/magicLink/MagicLinkService.d.ts +17 -15
  4. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  5. package/dist/auth/magicLink/MagicLinkService.js +49 -35
  6. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  7. package/dist/auth/magicLink/magicLinkCore.d.ts +14 -33
  8. package/dist/auth/magicLink/magicLinkCore.d.ts.map +1 -1
  9. package/dist/auth/magicLink/magicLinkCore.js +20 -52
  10. package/dist/auth/magicLink/magicLinkCore.js.map +1 -1
  11. package/dist/config.d.ts +53 -0
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +23 -0
  14. package/dist/config.js.map +1 -1
  15. package/dist/context.d.ts.map +1 -1
  16. package/dist/context.js +11 -23
  17. package/dist/context.js.map +1 -1
  18. package/dist/generic/CacheInvalidationResolver.d.ts +87 -3
  19. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
  20. package/dist/generic/CacheInvalidationResolver.js +118 -3
  21. package/dist/generic/CacheInvalidationResolver.js.map +1 -1
  22. package/dist/generic/DeleteOptionsInput.d.ts +8 -1
  23. package/dist/generic/DeleteOptionsInput.d.ts.map +1 -1
  24. package/dist/generic/DeleteOptionsInput.js +1 -1
  25. package/dist/generic/DeleteOptionsInput.js.map +1 -1
  26. package/dist/generic/ResolverBase.d.ts +4 -0
  27. package/dist/generic/ResolverBase.d.ts.map +1 -1
  28. package/dist/generic/ResolverBase.js +10 -3
  29. package/dist/generic/ResolverBase.js.map +1 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +49 -47
  32. package/dist/index.js.map +1 -1
  33. package/dist/postgresqlCompat.d.ts +8 -0
  34. package/dist/postgresqlCompat.d.ts.map +1 -0
  35. package/dist/postgresqlCompat.js +19 -0
  36. package/dist/postgresqlCompat.js.map +1 -0
  37. package/dist/postgresqlPoolSettings.d.ts +108 -0
  38. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  39. package/dist/postgresqlPoolSettings.js +134 -0
  40. package/dist/postgresqlPoolSettings.js.map +1 -0
  41. package/dist/resolvers/AdhocQueryResolver.d.ts +10 -20
  42. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  43. package/dist/resolvers/AdhocQueryResolver.js +54 -174
  44. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  45. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +44 -6
  46. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  47. package/dist/resolvers/RealtimeClientSessionResolver.js +90 -34
  48. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  49. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  50. package/dist/resolvers/RunAIAgentResolver.js +2 -1
  51. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  52. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  53. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  54. package/dist/resolvers/adhocTimeout.js +12 -0
  55. package/dist/resolvers/adhocTimeout.js.map +1 -0
  56. package/dist/services/TaskGraphActionRunner.d.ts +13 -8
  57. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  58. package/dist/services/TaskGraphActionRunner.js +16 -15
  59. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  60. package/package.json +92 -92
  61. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +12 -35
  62. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  63. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
  64. package/src/__tests__/DeleteOptionsInput.wireShape.test.ts +62 -0
  65. package/src/__tests__/RealtimeClientSessionResolver.test.ts +216 -2
  66. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +129 -0
  67. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  68. package/src/__tests__/cacheInvalidationRecordData.test.ts +177 -0
  69. package/src/__tests__/magicLink.test.ts +15 -76
  70. package/src/__tests__/magicLinkService.consume.test.ts +224 -0
  71. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  72. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  73. package/src/auth/magicLink/MagicLinkService.ts +55 -36
  74. package/src/auth/magicLink/magicLinkCore.ts +19 -56
  75. package/src/config.ts +23 -0
  76. package/src/context.ts +11 -24
  77. package/src/generic/CacheInvalidationResolver.ts +139 -3
  78. package/src/generic/DeleteOptionsInput.ts +9 -2
  79. package/src/generic/ResolverBase.ts +11 -3
  80. package/src/index.ts +55 -48
  81. package/src/postgresqlCompat.ts +21 -0
  82. package/src/postgresqlPoolSettings.ts +213 -0
  83. package/src/resolvers/AdhocQueryResolver.ts +64 -194
  84. package/src/resolvers/RealtimeClientSessionResolver.ts +93 -35
  85. package/src/resolvers/RunAIAgentResolver.ts +4 -1
  86. package/src/resolvers/adhocTimeout.ts +12 -0
  87. package/src/services/TaskGraphActionRunner.ts +16 -15
  88. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -27
  89. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  90. package/dist/resolvers/adhoc-query-helpers.js +0 -42
  91. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  92. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  93. package/src/resolvers/adhoc-query-helpers.ts +0 -50
@@ -26,7 +26,7 @@
26
26
  */
27
27
  import { Resolver, Mutation, Arg, Ctx, Int, Float, ObjectType, Field, PubSub, PubSubEngine } from 'type-graphql';
28
28
  import { AppContext, UserPayload } from '../types.js';
29
- import { AuthorizationEvaluator, UserInfo, IMetadataProvider, LogError, LogStatus, RunView } from '@memberjunction/core';
29
+ import { AuthorizationEvaluator, UserInfo, IMetadataProvider, LogError, LogStatus, RunView, BaseEntity } from '@memberjunction/core';
30
30
  import { UUIDsEqual, IsValidUUID } from '@memberjunction/global';
31
31
  import {
32
32
  MJAIAgentEntity,
@@ -376,6 +376,31 @@ export class RealtimeClientSessionResolver extends ResolverBase {
376
376
  private readonly clientSessionService = new RealtimeClientSessionService();
377
377
  private readonly sessionManager = new SessionManager(this.clientSessionService);
378
378
 
379
+ /**
380
+ * The reason a `Save()`/`Delete()` returned false, for a log line. `CompleteMessage` renders
381
+ * `Message`, `Error` and every `Errors[]` entry (validation errors included, since `Save()`
382
+ * runs `Validate()` and records its errors there), so there is no separate `Validate()` call.
383
+ * When a subclass refused the write without recording anything, say so explicitly instead of
384
+ * "unknown error", which is what hid MJ#4791.
385
+ *
386
+ * Only a NULL `LatestResult` proves the refusal happened before the provider (nothing was
387
+ * registered at all). A SUCCESS `LatestResult` is an earlier save on the same instance — the
388
+ * refusal registered nothing — so its text must never be reported as this failure's reason.
389
+ */
390
+ private describeSaveFailure(entity: BaseEntity): string {
391
+ const latest = entity.LatestResult;
392
+ if (!latest) {
393
+ return `no failure detail recorded (LatestResult null; ResultHistory length ${entity.ResultHistory.length}) ` +
394
+ '— the write was refused before reaching the provider';
395
+ }
396
+ if (latest.Success) {
397
+ return 'no failure detail recorded (latest result entry is a prior success; ' +
398
+ `ResultHistory length ${entity.ResultHistory.length})`;
399
+ }
400
+ const detail = latest.CompleteMessage?.trim();
401
+ return detail || `no failure detail recorded (LatestResult empty; ResultHistory length ${entity.ResultHistory.length})`;
402
+ }
403
+
379
404
  /**
380
405
  * Start a client-direct realtime voice session targeting `targetAgentId`.
381
406
  *
@@ -581,7 +606,9 @@ export class RealtimeClientSessionResolver extends ResolverBase {
581
606
  // Junction-link any artifacts the delegated run produced into the session's conversation
582
607
  // history (best-effort) — so chat, session review, and resume carryover can all see them.
583
608
  // Runs as `runUser`: the junction entity is not among an anonymous caller's relay grants.
584
- await this.linkDelegatedArtifactsToConversation(session, Artifacts, runUser, provider);
609
+ // The hidden anchor detail it may need to create, though, is written by the CALLER (#4791)
610
+ // — same reason `persistDirectActionTurn` is: the System user is not the conversation owner.
611
+ await this.linkDelegatedArtifactsToConversation(session, Artifacts, runUser, contextUser, provider);
585
612
 
586
613
  await this.sessionManager.Heartbeat(agentSessionId, contextUser, provider);
587
614
  return ResultJson;
@@ -645,7 +672,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
645
672
  session.Config_ = JSON.stringify(next);
646
673
  if (!(await session.Save())) {
647
674
  LogError(
648
- `RealtimeClientSessionResolver.updatePendingFeedbackRunID save failed: ${session.LatestResult?.CompleteMessage ?? 'unknown error'}`,
675
+ `RealtimeClientSessionResolver.updatePendingFeedbackRunID save failed for session ${session.ID}: ${this.describeSaveFailure(session)}`,
649
676
  );
650
677
  }
651
678
  }
@@ -1797,7 +1824,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1797
1824
  const saved = await session.Save();
1798
1825
  if (!saved) {
1799
1826
  LogError(
1800
- `RealtimeClientSessionResolver.persistObservabilityRunIDs save failed: ${session.LatestResult?.CompleteMessage ?? 'unknown error'}`,
1827
+ `RealtimeClientSessionResolver.persistObservabilityRunIDs save failed for session ${session.ID}: ${this.describeSaveFailure(session)}`,
1801
1828
  );
1802
1829
  }
1803
1830
  }
@@ -1915,7 +1942,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1915
1942
  if (!saved) {
1916
1943
  LogError(
1917
1944
  `SaveSessionChannelState: save failed for session ${agentSessionID} / channel ${channelID}: ` +
1918
- `${row.LatestResult?.CompleteMessage ?? 'unknown error'}`,
1945
+ `${this.describeSaveFailure(row)}`,
1919
1946
  );
1920
1947
  }
1921
1948
  return saved;
@@ -2200,7 +2227,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2200
2227
  artifact.UserID = contextUser.ID;
2201
2228
  artifact.Visibility = 'Always';
2202
2229
  if (!(await artifact.Save())) {
2203
- const message = `SaveSessionChannelArtifact: artifact save failed: ${artifact.LatestResult?.CompleteMessage ?? 'unknown error'}`;
2230
+ const message = `SaveSessionChannelArtifact: artifact save failed for session ${session.ID}: ${this.describeSaveFailure(artifact)}`;
2204
2231
  LogError(message);
2205
2232
  return { Success: false, ErrorMessage: message, ConversationDetailLinked: false };
2206
2233
  }
@@ -2212,7 +2239,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2212
2239
  version.Content = contentJson;
2213
2240
  version.UserID = contextUser.ID;
2214
2241
  if (!(await version.Save())) {
2215
- const message = `SaveSessionChannelArtifact: artifact version save failed: ${version.LatestResult?.CompleteMessage ?? 'unknown error'}`;
2242
+ const message = `SaveSessionChannelArtifact: artifact version save failed for session ${session.ID} (artifact ${artifact.ID}): ${this.describeSaveFailure(version)}`;
2216
2243
  LogError(message);
2217
2244
  return { Success: false, ErrorMessage: message, ArtifactID: artifact.ID, ConversationDetailLinked: false };
2218
2245
  }
@@ -2270,13 +2297,22 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2270
2297
  * queries (which scan junctions across ALL details) still surface the artifact. This is the
2271
2298
  * least-invasive correct anchor — no fake visible message, no orphaned artifact.
2272
2299
  *
2300
+ * TWO PRINCIPALS, deliberately: the lookup (`findLatestSessionDetailID`) and the junction row
2301
+ * itself (`saveDetailArtifactJunction`, `MJ: Conversation Detail Artifacts`) run as `runUser`
2302
+ * (elevated for a scoped-anonymous caller) — that junction entity is outside an anonymous
2303
+ * caller's relay grants. The hidden ANCHOR `Conversation Detail` this may need to create runs as
2304
+ * `callerUser` instead: like every other `MJ: Conversation Details` write, the System user is
2305
+ * refused because it is not the conversation's owner (see {@link persistDirectActionTurn},
2306
+ * issue #4791) — only the actual caller can create it.
2307
+ *
2273
2308
  * Strictly best-effort: every failure path logs and returns; a relayed tool call NEVER fails
2274
2309
  * because history linking did.
2275
2310
  */
2276
2311
  private async linkDelegatedArtifactsToConversation(
2277
2312
  session: MJAIAgentSessionEntity,
2278
2313
  artifacts: DelegatedRunArtifact[] | undefined,
2279
- contextUser: UserInfo,
2314
+ runUser: UserInfo,
2315
+ callerUser: UserInfo,
2280
2316
  provider: IMetadataProvider,
2281
2317
  ): Promise<void> {
2282
2318
  if (!artifacts || artifacts.length === 0 || !session.ConversationID) {
@@ -2284,13 +2320,13 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2284
2320
  }
2285
2321
  try {
2286
2322
  const detailID =
2287
- (await this.findLatestSessionDetailID(session, contextUser, provider)) ??
2288
- (await this.createHiddenSessionAnchorDetail(session, contextUser, provider));
2323
+ (await this.findLatestSessionDetailID(session, runUser, provider)) ??
2324
+ (await this.createHiddenSessionAnchorDetail(session, callerUser, provider));
2289
2325
  if (!detailID) {
2290
2326
  return; // anchor unavailable — logged in the helpers
2291
2327
  }
2292
2328
  for (const artifact of artifacts) {
2293
- await this.saveDetailArtifactJunction(detailID, artifact.ArtifactVersionID, contextUser, provider);
2329
+ await this.saveDetailArtifactJunction(detailID, artifact.ArtifactVersionID, runUser, provider);
2294
2330
  }
2295
2331
  } catch (error) {
2296
2332
  LogError(`ExecuteRealtimeSessionTool: delegated-artifact history link failed: ${(error as Error).message}`);
@@ -2331,25 +2367,25 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2331
2367
  */
2332
2368
  private async createHiddenSessionAnchorDetail(
2333
2369
  session: MJAIAgentSessionEntity,
2334
- contextUser: UserInfo,
2370
+ callerUser: UserInfo,
2335
2371
  provider: IMetadataProvider,
2336
2372
  ): Promise<string | null> {
2337
- const detail = await provider.GetEntityObject<MJConversationDetailEntity>(CONVERSATION_DETAIL_ENTITY, contextUser);
2373
+ const detail = await provider.GetEntityObject<MJConversationDetailEntity>(CONVERSATION_DETAIL_ENTITY, callerUser);
2338
2374
  detail.NewRecord();
2339
2375
  detail.ConversationID = session.ConversationID;
2340
2376
  detail.Role = 'AI';
2341
2377
  detail.HiddenToUser = true;
2342
2378
  detail.Message = 'Artifacts produced during a realtime session (system anchor).';
2343
2379
  detail.AgentSessionID = session.ID;
2344
- // Attribute the anchor to the SESSION owner, not the (possibly elevated) writer — identical
2345
- // for every non-elevated caller, whose ownership of the session is already proven.
2380
+ // Attribute to the SESSION owner — the caller writing this row, whose ownership
2381
+ // `loadOwnedActiveSession` already proved.
2346
2382
  detail.UserID = session.UserID;
2347
2383
  if (await detail.Save()) {
2348
2384
  return detail.ID;
2349
2385
  }
2350
2386
  LogError(
2351
2387
  `ExecuteRealtimeSessionTool: hidden anchor detail save failed for session ${session.ID}: ` +
2352
- `${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2388
+ `${this.describeSaveFailure(detail)}`,
2353
2389
  );
2354
2390
  return null;
2355
2391
  }
@@ -2376,7 +2412,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2376
2412
  if (!saved) {
2377
2413
  LogError(
2378
2414
  `RealtimeClientSessionResolver: artifact junction save failed for detail ${conversationDetailID} / version ${artifactVersionID}: ` +
2379
- `${junction.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2415
+ `${this.describeSaveFailure(junction)}`,
2380
2416
  );
2381
2417
  }
2382
2418
  return saved;
@@ -2416,7 +2452,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2416
2452
  if (!(await row.Save())) {
2417
2453
  LogError(
2418
2454
  `SaveSessionChannelArtifact: LastActiveAt stamp failed for session ${agentSessionID} / channel ${channelID}: ` +
2419
- `${row.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2455
+ `${this.describeSaveFailure(row)}`,
2420
2456
  );
2421
2457
  }
2422
2458
  } catch (error) {
@@ -2560,12 +2596,6 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2560
2596
  return minutes && minutes > 0 ? minutes * 60 : undefined;
2561
2597
  }
2562
2598
 
2563
- /**
2564
- * Persists a single transcript turn as a `Conversation Detail` stamped with the session's
2565
- * conversation, the mapped role, the turn text, the session id, and the owning user.
2566
- *
2567
- * @returns The boolean save result (logs `CompleteMessage` on failure).
2568
- */
2569
2599
  /**
2570
2600
  * Stamps `RecordingStartedAt` (the recording `t0` alignment origin) + `RecordingMedia` on a
2571
2601
  * just-started session when the browser captured WITH consent. Best-effort: a parse/save failure
@@ -2592,11 +2622,18 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2592
2622
  session.RecordingMedia = 'Audio';
2593
2623
  if (!(await session.Save())) {
2594
2624
  LogError(
2595
- `RealtimeClientSessionResolver.stampRecordingStart save failed: ${session.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2625
+ `RealtimeClientSessionResolver.stampRecordingStart save failed for session ${session.ID}: ${this.describeSaveFailure(session)}`,
2596
2626
  );
2597
2627
  }
2598
2628
  }
2599
2629
 
2630
+ /**
2631
+ * Persists a single transcript turn as a `Conversation Detail` stamped with the session's
2632
+ * conversation, the mapped role, the turn text, the session id, and the owning user.
2633
+ *
2634
+ * @returns The boolean save result. On failure it logs the session, the write user and the
2635
+ * reason from {@link describeSaveFailure}.
2636
+ */
2600
2637
  private async persistTranscriptTurn(
2601
2638
  session: MJAIAgentSessionEntity,
2602
2639
  role: string,
@@ -2626,7 +2663,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2626
2663
  const saved = await detail.Save();
2627
2664
  if (!saved) {
2628
2665
  LogError(
2629
- `RealtimeClientSessionResolver.persistTranscriptTurn save failed: ${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2666
+ `RealtimeClientSessionResolver.persistTranscriptTurn save failed for session ${session.ID} (write user ${contextUser.ID}): ${this.describeSaveFailure(detail)}`,
2630
2667
  );
2631
2668
  }
2632
2669
  return saved;
@@ -2667,8 +2704,13 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2667
2704
  // ⚠️ ELEVATING INSTEAD WAS TRIED AND IS A TRAP. `ResolveScopedAnonymousRunUser` returns
2668
2705
  // `UserCache.GetSystemUser()`, which on this deployment resolves to the unconfigured
2669
2706
  // placeholder `not.set@nowhere.com` ("Configured provisioning user not found; falling back
2670
- // to an Owner"). Saving as a user that does not exist returns false with a NULL
2671
- // `LatestResult` — indistinguishable from a permission denial, and just as silent.
2707
+ // to an Owner"). That user EXISTS — it is not a missing/invalid principal — and even carries
2708
+ // full CRUD on `MJ: Conversation Details`. It is refused anyway:
2709
+ // `MJConversationDetailEntityExtended`'s owner gate accepts only the conversation's OWNER or
2710
+ // an Edit/Owner Resource Permission grantee, and the System user is neither. Measured on
2711
+ // MJ#4791 (before the core-entities fix that now records the denial): `Save()` → `false`,
2712
+ // `LatestResult` null — indistinguishable from a permission denial, and just as silent. The
2713
+ // denial itself still happens; that fix only made it stop being silent.
2672
2714
  const writeUser = contextUser;
2673
2715
  const rv = RunView.FromMetadataProvider(provider);
2674
2716
  const result = await rv.RunView<MJConversationDetailEntity>(
@@ -2689,8 +2731,8 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2689
2731
  //
2690
2732
  // `ResultType: 'entity_object'` hands back hydrated entities, but they do not carry the
2691
2733
  // context user the way `GetEntityObject(entity, user)` does — so `Save()` ran with no
2692
- // principal and failed with an EMPTY message ("unknown error"), which is what the elevation
2693
- // fix above looked like when it was still broken. The insert path beside this one has
2734
+ // principal and failed with an EMPTY message ("unknown error"), which is what an empty-message
2735
+ // failure here looked like before the re-load. The insert path beside this one has
2694
2736
  // always used `GetEntityObject`; this now matches it, which is also why they now succeed
2695
2737
  // and fail for the same reasons.
2696
2738
  const previous = await provider.GetEntityObject<MJConversationDetailEntity>(
@@ -2722,7 +2764,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2722
2764
  `RealtimeClientSessionResolver.replacePreviousTranscriptTurn save failed for session `
2723
2765
  + `${session.ID} — the ${mappedRole} turn keeps its PREVIOUS, shorter text and the `
2724
2766
  + `transcript now understates what was said: `
2725
- + `${previous.LatestResult?.CompleteMessage || JSON.stringify(previous.LatestResult ?? null)}`,
2767
+ + `${this.describeSaveFailure(previous)}`,
2726
2768
  );
2727
2769
  }
2728
2770
  return saved;
@@ -2742,6 +2784,23 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2742
2784
  * Stored as stable facts (Role: 'AI', HiddenToUser: true, ExternalID: callId, CompletionTime: durationMs,
2743
2785
  * Message: JSON-serialized tool facts) so that session review can reconstruct the action card dynamically
2744
2786
  * on load (Option 1) without polluting user-facing speech chat bubbles.
2787
+ *
2788
+ * Writes as the CALLER, never the elevated run user (issue #4791). Owning the session
2789
+ * (`loadOwnedActiveSession`'s gate has already run on every path here) does NOT imply owning
2790
+ * the conversation: the conversation id was client-supplied at session start. What authorizes
2791
+ * this write is the save itself, evaluated for the caller — the conversation owner gate in
2792
+ * `MJConversationDetailEntityExtended`, plus the caller's own entity permission and Create RLS
2793
+ * on `MJ: Conversation Details` (for the magic-link role that RLS scopes by the invite's
2794
+ * `ScopeResourceID`, and the role belongs to a downstream app, not MJ core). That is why
2795
+ * writing as the caller is safe even though the conversation id originated client-side.
2796
+ * Elevating to the System user would bypass RLS entirely, and the owner gate refuses it anyway
2797
+ * because the System user is not the conversation owner. Measured on MJ#4791 (before the
2798
+ * core-entities fix that now records the denial): `Save()` returned `false` with a NULL `LatestResult` and an empty
2799
+ * `ResultHistory` — indistinguishable from any other silent failure. Elevating this write is
2800
+ * what silently dropped both hidden tool-execution turns of a real magic-link session; the
2801
+ * denial itself still happens today, only its silence was fixed separately.
2802
+ * `ResolveScopedAnonymousRunUser` elevates AI-**run**-entity writes only (prompt runs, agent
2803
+ * runs, the relayed dispatch itself) — never this one.
2745
2804
  */
2746
2805
  private async persistDirectActionTurn(
2747
2806
  session: MJAIAgentSessionEntity,
@@ -2757,10 +2816,9 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2757
2816
  return false;
2758
2817
  }
2759
2818
  try {
2760
- const writeUser = ResolveScopedAnonymousRunUser(contextUser);
2761
2819
  const detail = await provider.GetEntityObject<MJConversationDetailEntity>(
2762
2820
  CONVERSATION_DETAIL_ENTITY,
2763
- writeUser,
2821
+ contextUser,
2764
2822
  );
2765
2823
  detail.NewRecord();
2766
2824
  detail.ConversationID = session.ConversationID;
@@ -2802,13 +2860,13 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2802
2860
  const saved = await detail.Save();
2803
2861
  if (!saved) {
2804
2862
  LogError(
2805
- `RealtimeClientSessionResolver.persistDirectActionTurn save failed: ${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2863
+ `RealtimeClientSessionResolver.persistDirectActionTurn save failed for session ${session.ID} (tool '${toolName}', call ${callId ?? 'n/a'}, write user ${contextUser.ID}): ${this.describeSaveFailure(detail)}`,
2806
2864
  );
2807
2865
  }
2808
2866
  return saved;
2809
2867
  } catch (error) {
2810
2868
  const message = error instanceof Error ? error.message : String(error);
2811
- LogError(`RealtimeClientSessionResolver.persistDirectActionTurn unexpected error: ${message}`);
2869
+ LogError(`RealtimeClientSessionResolver.persistDirectActionTurn unexpected error for session ${session.ID}: ${message}`);
2812
2870
  return false;
2813
2871
  }
2814
2872
  }
@@ -598,7 +598,10 @@ export class RunAIAgentResolver extends ResolverBase {
598
598
  detail.Message = errorMessage;
599
599
  detail.Error = errorMessage;
600
600
  if (!(await detail.Save())) {
601
- LogError(`Failed to persist Error on conversation detail ${conversationDetailId}`);
601
+ LogError(
602
+ `Failed to persist Error on conversation detail ${conversationDetailId}: ` +
603
+ `${detail.LatestResult?.CompleteMessage?.trim() || 'no failure detail recorded'}`,
604
+ );
602
605
  }
603
606
  }
604
607
  }
@@ -0,0 +1,12 @@
1
+ /** The timeout an ad-hoc query gets when the caller names none. */
2
+ const DEFAULT_TIMEOUT_SECONDS = 30;
3
+
4
+ /**
5
+ * The timeout an ad-hoc query runs with: what the caller asked for (or the default), but never
6
+ * longer than the server's `requestTimeout`, so a caller cannot hold a read-only connection longer
7
+ * than any other request may. `requestTimeoutMs` of 0 or less means the server sets no limit.
8
+ */
9
+ export function ClampAdhocTimeoutSeconds(requested: number | undefined, requestTimeoutMs: number): number {
10
+ const seconds = requested != null && requested > 0 ? requested : DEFAULT_TIMEOUT_SECONDS;
11
+ return requestTimeoutMs > 0 ? Math.min(seconds, Math.max(1, Math.floor(requestTimeoutMs / 1000))) : seconds;
12
+ }
@@ -75,25 +75,26 @@ export class TaskGraphActionRunner implements TaskActionRunner {
75
75
  }
76
76
 
77
77
  /**
78
- * Rebuilds the action's parameters from the task's stored payload.
78
+ * Rebuilds the action's parameters from the task's input, one parameter per key.
79
79
  *
80
- * **The values here have been through redaction**, because `Task.InputPayload` is persistent,
81
- * user-visible storage and nothing writes a raw `ActionParam[]` there. A parameter the binding
82
- * marked as not-logged therefore arrives absent rather than secret — the action sees a missing
83
- * value, which is the honest consequence of choosing not to persist it, and the reason durable
84
- * dispatch is opt-in per binding rather than the default.
80
+ * For a **durable entity-action** graph the input was written by `RedactParamsToRecord`
81
+ * (`@memberjunction/actions-base`) at deferral time, so its values have been through redaction:
82
+ * a parameter the binding marked as not-logged arrives absent rather than secret — the action
83
+ * sees a missing value, which is the honest consequence of choosing not to persist it, and the
84
+ * reason durable dispatch is opt-in per binding rather than the default. Every other graph's
85
+ * input is whatever its spec authored. This method re-applies no redaction rule in either case.
85
86
  *
86
- * Dependency outputs are merged underneath the task's own input so a node's explicit parameters
87
- * always win over an upstream node that happened to emit the same key.
87
+ * `InputPayload` already carries the dependency outputs: the dispatcher merges them underneath
88
+ * the task's own input (`mergedPayload`) before calling this runner, so a node's explicit
89
+ * parameters win and a step with an input mapping gets exactly the parameters it declared.
90
+ * `DependencyOutputs` is keyed by upstream task ID, not by parameter name, so it is deliberately
91
+ * not read here — spreading it would hand the action one extra parameter per dependency, named
92
+ * by a GUID and holding that task's whole output.
88
93
  */
89
94
  private buildParams(params: TaskActionRunParams): ActionParam[] {
90
- const merged: Record<string, unknown> = {};
91
- for (const [name, value] of params.DependencyOutputs) {
92
- merged[name] = value;
95
+ if (!params.InputPayload || typeof params.InputPayload !== 'object') {
96
+ return [];
93
97
  }
94
- if (params.InputPayload && typeof params.InputPayload === 'object') {
95
- Object.assign(merged, params.InputPayload as Record<string, unknown>);
96
- }
97
- return Object.entries(merged).map(([Name, Value]) => ({ Name, Value, Type: 'Input' }));
98
+ return Object.entries(params.InputPayload as Record<string, unknown>).map(([Name, Value]) => ({ Name, Value, Type: 'Input' }));
98
99
  }
99
100
  }
@@ -1,27 +0,0 @@
1
- /**
2
- * Pure, dependency-free helpers for {@link AdhocQueryResolver}. Kept in their own
3
- * module (no type-graphql / mssql imports) so the total-row-count boundary logic is
4
- * unit-testable without standing up the GraphQL resolver or a database.
5
- */
6
- /**
7
- * Determines the exact total row count from the page alone, when it can be known
8
- * without a separate `COUNT(*)`:
9
- *
10
- * - **Not paging** (`maxRows == null`): the query ran uncapped, so every row came
11
- * back — the total is just the number returned.
12
- * - **Short page** (`pageLength < maxRows`): a page shorter than the page size means
13
- * there are no rows beyond it, so the total is `startRow + pageLength` exactly.
14
- * - **Full page** (`pageLength >= maxRows`): more rows may exist — returns `null` to
15
- * signal that a `COUNT(*)` is required to know the true total.
16
- */
17
- export declare function exactTotalFromPage(startRow: number, pageLength: number, maxRows: number | null): number | null;
18
- /**
19
- * Reads the total row count from a `COUNT(*)` recordset (`[{ TotalRowCount }]`),
20
- * falling back to `fallback` when the count is absent, non-numeric, or negative — so
21
- * an unexpected count shape never yields a misleading total. `fallback` should be a
22
- * safe lower bound (e.g. `startRow + rowsReturned`).
23
- */
24
- export declare function resolveAdhocTotalRowCount(countRows: ReadonlyArray<{
25
- TotalRowCount?: unknown;
26
- }> | null | undefined, fallback: number): number;
27
- //# sourceMappingURL=adhoc-query-helpers.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"adhoc-query-helpers.d.ts","sourceRoot":"","sources":["../../src/resolvers/adhoc-query-helpers.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAC9B,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,GAAG,IAAI,GACvB,MAAM,GAAG,IAAI,CAQf;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CACrC,SAAS,EAAE,aAAa,CAAC;IAAE,aAAa,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC,GAAG,IAAI,GAAG,SAAS,EACxE,QAAQ,EAAE,MAAM,GACjB,MAAM,CASR"}
@@ -1,42 +0,0 @@
1
- /**
2
- * Pure, dependency-free helpers for {@link AdhocQueryResolver}. Kept in their own
3
- * module (no type-graphql / mssql imports) so the total-row-count boundary logic is
4
- * unit-testable without standing up the GraphQL resolver or a database.
5
- */
6
- /**
7
- * Determines the exact total row count from the page alone, when it can be known
8
- * without a separate `COUNT(*)`:
9
- *
10
- * - **Not paging** (`maxRows == null`): the query ran uncapped, so every row came
11
- * back — the total is just the number returned.
12
- * - **Short page** (`pageLength < maxRows`): a page shorter than the page size means
13
- * there are no rows beyond it, so the total is `startRow + pageLength` exactly.
14
- * - **Full page** (`pageLength >= maxRows`): more rows may exist — returns `null` to
15
- * signal that a `COUNT(*)` is required to know the true total.
16
- */
17
- export function exactTotalFromPage(startRow, pageLength, maxRows) {
18
- if (maxRows == null) {
19
- return pageLength;
20
- }
21
- if (pageLength < maxRows) {
22
- return startRow + pageLength;
23
- }
24
- return null;
25
- }
26
- /**
27
- * Reads the total row count from a `COUNT(*)` recordset (`[{ TotalRowCount }]`),
28
- * falling back to `fallback` when the count is absent, non-numeric, or negative — so
29
- * an unexpected count shape never yields a misleading total. `fallback` should be a
30
- * safe lower bound (e.g. `startRow + rowsReturned`).
31
- */
32
- export function resolveAdhocTotalRowCount(countRows, fallback) {
33
- const raw = countRows?.[0]?.TotalRowCount;
34
- if (raw != null) {
35
- const n = Number(raw);
36
- if (Number.isFinite(n) && n >= 0) {
37
- return Math.floor(n);
38
- }
39
- }
40
- return fallback;
41
- }
42
- //# sourceMappingURL=adhoc-query-helpers.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"adhoc-query-helpers.js","sourceRoot":"","sources":["../../src/resolvers/adhoc-query-helpers.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;;;;;;;;;GAUG;AACH,MAAM,UAAU,kBAAkB,CAC9B,QAAgB,EAChB,UAAkB,EAClB,OAAsB;IAEtB,IAAI,OAAO,IAAI,IAAI,EAAE,CAAC;QAClB,OAAO,UAAU,CAAC;IACtB,CAAC;IACD,IAAI,UAAU,GAAG,OAAO,EAAE,CAAC;QACvB,OAAO,QAAQ,GAAG,UAAU,CAAC;IACjC,CAAC;IACD,OAAO,IAAI,CAAC;AAChB,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,yBAAyB,CACrC,SAAwE,EACxE,QAAgB;IAEhB,MAAM,GAAG,GAAG,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,aAAa,CAAC;IAC1C,IAAI,GAAG,IAAI,IAAI,EAAE,CAAC;QACd,MAAM,CAAC,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;QACtB,IAAI,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;YAC/B,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QACzB,CAAC;IACL,CAAC;IACD,OAAO,QAAQ,CAAC;AACpB,CAAC"}
@@ -1,50 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { exactTotalFromPage, resolveAdhocTotalRowCount } from '../resolvers/adhoc-query-helpers';
3
-
4
- describe('exactTotalFromPage', () => {
5
- it('returns the row count when not paging (maxRows null) — the query was uncapped', () => {
6
- expect(exactTotalFromPage(0, 47, null)).toBe(47);
7
- expect(exactTotalFromPage(0, 0, null)).toBe(0);
8
- });
9
-
10
- it('returns startRow + pageLength for a SHORT page (fewer rows than the page size)', () => {
11
- expect(exactTotalFromPage(0, 47, 100)).toBe(47); // page 1, short → exact total
12
- expect(exactTotalFromPage(200, 30, 100)).toBe(230); // page 3, short last page
13
- expect(exactTotalFromPage(100, 0, 100)).toBe(100); // empty page past the end
14
- });
15
-
16
- it('returns null for a FULL page — a COUNT is required because more rows may exist', () => {
17
- expect(exactTotalFromPage(0, 100, 100)).toBeNull();
18
- expect(exactTotalFromPage(500, 100, 100)).toBeNull();
19
- });
20
-
21
- it('treats an over-full page defensively as "needs count" (should not occur with OFFSET/FETCH)', () => {
22
- expect(exactTotalFromPage(0, 150, 100)).toBeNull();
23
- });
24
- });
25
-
26
- describe('resolveAdhocTotalRowCount', () => {
27
- it('reads TotalRowCount from the count recordset', () => {
28
- expect(resolveAdhocTotalRowCount([{ TotalRowCount: 2087 }], 100)).toBe(2087);
29
- expect(resolveAdhocTotalRowCount([{ TotalRowCount: 0 }], 100)).toBe(0);
30
- });
31
-
32
- it('floors a fractional/stringified count', () => {
33
- expect(resolveAdhocTotalRowCount([{ TotalRowCount: 42.9 }], 0)).toBe(42);
34
- expect(resolveAdhocTotalRowCount([{ TotalRowCount: '2087' as unknown as number }], 0)).toBe(2087);
35
- });
36
-
37
- it('falls back when the count is absent, empty, null, or malformed', () => {
38
- expect(resolveAdhocTotalRowCount(null, 100)).toBe(100);
39
- expect(resolveAdhocTotalRowCount(undefined, 100)).toBe(100);
40
- expect(resolveAdhocTotalRowCount([], 100)).toBe(100);
41
- expect(resolveAdhocTotalRowCount([{}], 100)).toBe(100);
42
- expect(resolveAdhocTotalRowCount([{ TotalRowCount: null }], 100)).toBe(100);
43
- });
44
-
45
- it('falls back on non-finite or negative counts (never reports a misleading total)', () => {
46
- expect(resolveAdhocTotalRowCount([{ TotalRowCount: Number.NaN }], 100)).toBe(100);
47
- expect(resolveAdhocTotalRowCount([{ TotalRowCount: -5 }], 100)).toBe(100);
48
- expect(resolveAdhocTotalRowCount([{ TotalRowCount: Infinity }], 100)).toBe(100);
49
- });
50
- });
@@ -1,50 +0,0 @@
1
- /**
2
- * Pure, dependency-free helpers for {@link AdhocQueryResolver}. Kept in their own
3
- * module (no type-graphql / mssql imports) so the total-row-count boundary logic is
4
- * unit-testable without standing up the GraphQL resolver or a database.
5
- */
6
-
7
- /**
8
- * Determines the exact total row count from the page alone, when it can be known
9
- * without a separate `COUNT(*)`:
10
- *
11
- * - **Not paging** (`maxRows == null`): the query ran uncapped, so every row came
12
- * back — the total is just the number returned.
13
- * - **Short page** (`pageLength < maxRows`): a page shorter than the page size means
14
- * there are no rows beyond it, so the total is `startRow + pageLength` exactly.
15
- * - **Full page** (`pageLength >= maxRows`): more rows may exist — returns `null` to
16
- * signal that a `COUNT(*)` is required to know the true total.
17
- */
18
- export function exactTotalFromPage(
19
- startRow: number,
20
- pageLength: number,
21
- maxRows: number | null,
22
- ): number | null {
23
- if (maxRows == null) {
24
- return pageLength;
25
- }
26
- if (pageLength < maxRows) {
27
- return startRow + pageLength;
28
- }
29
- return null;
30
- }
31
-
32
- /**
33
- * Reads the total row count from a `COUNT(*)` recordset (`[{ TotalRowCount }]`),
34
- * falling back to `fallback` when the count is absent, non-numeric, or negative — so
35
- * an unexpected count shape never yields a misleading total. `fallback` should be a
36
- * safe lower bound (e.g. `startRow + rowsReturned`).
37
- */
38
- export function resolveAdhocTotalRowCount(
39
- countRows: ReadonlyArray<{ TotalRowCount?: unknown }> | null | undefined,
40
- fallback: number,
41
- ): number {
42
- const raw = countRows?.[0]?.TotalRowCount;
43
- if (raw != null) {
44
- const n = Number(raw);
45
- if (Number.isFinite(n) && n >= 0) {
46
- return Math.floor(n);
47
- }
48
- }
49
- return fallback;
50
- }