@memberjunction/server 6.1.3 → 6.2.0-edge.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 (66) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/auth/exampleNewUserSubClass.js +2 -1
  3. package/dist/auth/exampleNewUserSubClass.js.map +1 -1
  4. package/dist/generated/graphql-schemas/__mj.d.ts +255 -4
  5. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  6. package/dist/generated/graphql-schemas/__mj.js +1256 -0
  7. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  8. package/dist/generic/ResolverBase.d.ts +26 -0
  9. package/dist/generic/ResolverBase.d.ts.map +1 -1
  10. package/dist/generic/ResolverBase.js +59 -1
  11. package/dist/generic/ResolverBase.js.map +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +12 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/resolvers/FetchEntityVectorsResolver.js +2 -2
  16. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  17. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  18. package/dist/resolvers/QuerySystemUserResolver.js +2 -1
  19. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  20. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +37 -0
  21. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  22. package/dist/resolvers/RealtimeClientSessionResolver.js +98 -8
  23. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  24. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  25. package/dist/resolvers/RunAIAgentResolver.js +5 -4
  26. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  27. package/dist/resolvers/SearchEntitiesResolver.d.ts.map +1 -1
  28. package/dist/resolvers/SearchEntitiesResolver.js +3 -1
  29. package/dist/resolvers/SearchEntitiesResolver.js.map +1 -1
  30. package/dist/resolvers/SearchKnowledgeResolver.d.ts +1 -0
  31. package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
  32. package/dist/resolvers/SearchKnowledgeResolver.js +5 -0
  33. package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
  34. package/dist/resolvers/SearchKnowledgeSystemUserResolver.d.ts.map +1 -1
  35. package/dist/resolvers/SearchKnowledgeSystemUserResolver.js +1 -0
  36. package/dist/resolvers/SearchKnowledgeSystemUserResolver.js.map +1 -1
  37. package/dist/resolvers/TransactionGroupResolver.d.ts.map +1 -1
  38. package/dist/resolvers/TransactionGroupResolver.js +53 -4
  39. package/dist/resolvers/TransactionGroupResolver.js.map +1 -1
  40. package/dist/services/MJStorageBlobStore.d.ts +41 -0
  41. package/dist/services/MJStorageBlobStore.d.ts.map +1 -0
  42. package/dist/services/MJStorageBlobStore.js +160 -0
  43. package/dist/services/MJStorageBlobStore.js.map +1 -0
  44. package/dist/util.d.ts +6 -3
  45. package/dist/util.d.ts.map +1 -1
  46. package/dist/util.js +6 -3
  47. package/dist/util.js.map +1 -1
  48. package/package.json +92 -92
  49. package/src/__tests__/RealtimeClientSessionResolver.test.ts +229 -0
  50. package/src/__tests__/ResolverBase.isaPromotionCreate.test.ts +412 -0
  51. package/src/__tests__/SearchEntitiesResolver.readOnlyFallback.test.ts +78 -0
  52. package/src/__tests__/TransactionGroupResolver.refusals.test.ts +290 -0
  53. package/src/auth/exampleNewUserSubClass.ts +2 -2
  54. package/src/generated/graphql-schemas/__mj.ts +896 -5
  55. package/src/generic/ResolverBase.ts +61 -0
  56. package/src/index.ts +14 -1
  57. package/src/resolvers/FetchEntityVectorsResolver.ts +2 -2
  58. package/src/resolvers/QuerySystemUserResolver.ts +2 -1
  59. package/src/resolvers/RealtimeClientSessionResolver.ts +123 -10
  60. package/src/resolvers/RunAIAgentResolver.ts +5 -4
  61. package/src/resolvers/SearchEntitiesResolver.ts +3 -1
  62. package/src/resolvers/SearchKnowledgeResolver.ts +4 -0
  63. package/src/resolvers/SearchKnowledgeSystemUserResolver.ts +1 -0
  64. package/src/resolvers/TransactionGroupResolver.ts +57 -4
  65. package/src/services/MJStorageBlobStore.ts +209 -0
  66. package/src/util.ts +6 -3
@@ -10,6 +10,7 @@ import {
10
10
  EntitySaveOptions,
11
11
  IMetadataProvider,
12
12
  IRunViewProvider,
13
+ KeyValuePair,
13
14
  LogDebug,
14
15
  LogError,
15
16
  LogStatus,
@@ -1521,6 +1522,62 @@ export class ResolverBase {
1521
1522
  }
1522
1523
  }
1523
1524
 
1525
+ /**
1526
+ * IS-A PROMOTION over the wire: an EXISTING parent record gaining a subtype — an Animal that is
1527
+ * now also a Dog. It arrives as the CHILD's create mutation carrying the existing parent's
1528
+ * primary key, because in IS-A the shared key is the relationship and the child has no key of
1529
+ * its own to mint. `NewRecord()` has just reset the whole chain to "new", so left alone the
1530
+ * parent would save as a CREATE: a second copy of a row that already exists (a unique-constraint
1531
+ * violation at best, a silently duplicated parent at worst).
1532
+ *
1533
+ * When a child create carries a complete primary key, bind the new child to the existing parent
1534
+ * row with `AttachToParent` (#3825): it loads the parent chain, so the parent saves as an UPDATE
1535
+ * and only the child is INSERTed. A key that matches no row leaves the fresh chain exactly as
1536
+ * `NewRecord()` built it — the ordinary whole-chain create. A non-IS-A entity, or a create with
1537
+ * no key, never gets past the guards.
1538
+ *
1539
+ * @returns true when the child was attached to an existing parent row.
1540
+ */
1541
+ protected async attachToExistingParentIfPromotion(entityObject: BaseEntity, input: Record<string, unknown>): Promise<boolean> {
1542
+ if (!entityObject.EntityInfo.IsChildType) {
1543
+ return false;
1544
+ }
1545
+ const key = this.primaryKeyFromInput(entityObject.EntityInfo, input);
1546
+ if (!key) {
1547
+ return false;
1548
+ }
1549
+ const attached = await entityObject.AttachToParent(key);
1550
+ if (!attached) {
1551
+ // No parent row under that key: this is a whole-chain create on the CALLER's key (the
1552
+ // client mints the shared key at the root and sends it, so honoring it keeps the client's
1553
+ // in-memory chain and the stored rows on one key). AttachToParent restored the fresh chain,
1554
+ // but the root's ReadOnly key slot is no longer writable — a later SetMany would silently
1555
+ // keep the server-minted value — so re-seed the chain with the supplied key explicitly.
1556
+ entityObject.NewRecord(key);
1557
+ }
1558
+ return attached;
1559
+ }
1560
+
1561
+ /**
1562
+ * The entity's primary key as supplied on a mutation input, or null when any part of it is
1563
+ * missing. Composite keys are all-or-nothing: a half-specified key identifies nothing. An empty
1564
+ * string counts as missing, the same as null — it is never a valid key value, and a GUID or
1565
+ * string key that is "" identifies no row, so the create proceeds as a whole-chain create rather
1566
+ * than failing the lookup. A malformed non-empty key (e.g. a non-GUID for a uniqueidentifier)
1567
+ * is NOT swallowed: the load throws and the mutation fails loudly.
1568
+ */
1569
+ protected primaryKeyFromInput(entityInfo: EntityInfo, input: Record<string, unknown>): CompositeKey | null {
1570
+ const pairs: KeyValuePair[] = [];
1571
+ for (const pk of entityInfo.PrimaryKeys) {
1572
+ const value = input[pk.Name] ?? input[pk.CodeName];
1573
+ if (value === null || value === undefined || value === '') {
1574
+ return null;
1575
+ }
1576
+ pairs.push(new KeyValuePair(pk.Name, value));
1577
+ }
1578
+ return pairs.length > 0 ? CompositeKey.FromKeyValuePairs(pairs) : null;
1579
+ }
1580
+
1524
1581
  protected async CreateRecord(entityName: string, input: any, provider: DatabaseProviderBase, userPayload: UserPayload, pubSub: PubSubEngine) {
1525
1582
  // Check API key scope authorization for entity create operations
1526
1583
  await this.CheckAPIKeyScopeAuthorization('entity:create', entityName, userPayload);
@@ -1538,6 +1595,10 @@ export class ResolverBase {
1538
1595
  for (const key of Object.keys(input)) {
1539
1596
  if (key !== 'RestoreContext___') fieldsForSet[key] = input[key];
1540
1597
  }
1598
+ // IS-A promotion: bind the new child to its EXISTING parent row BEFORE the field
1599
+ // assignments, so values the client sent for parent fields land on the loaded parent
1600
+ // (as an update) instead of being wiped by the load.
1601
+ await this.attachToExistingParentIfPromotion(entityObject, fieldsForSet);
1541
1602
  entityObject.SetMany(fieldsForSet);
1542
1603
 
1543
1604
  // Reconstruct the client-side restore context, if any, on this server
package/src/index.ts CHANGED
@@ -67,6 +67,8 @@ import { RegisterRSUProgressBridge } from './integration/RSUProgressBridge.js';
67
67
  import { ClientToolRequestManager, AgentRunWatchdog } from '@memberjunction/ai-agents';
68
68
  import { SessionJanitor } from './agentSessions/index.js';
69
69
  import { StartTaskGraphDispatcher } from './services/StartTaskGraphDispatcher.js';
70
+ import { GetAttachmentService } from '@memberjunction/aiengine';
71
+ import { MJStorageBlobStore } from './services/MJStorageBlobStore.js';
70
72
  import { CACHE_INVALIDATION_TOPIC } from './generic/CacheInvalidationResolver.js';
71
73
  import { ConnectorFactory, IntegrationEngine, IntegrationSyncOptions } from '@memberjunction/integration-engine';
72
74
  import { CronExpressionHelper } from '@memberjunction/scheduling-engine';
@@ -281,6 +283,17 @@ function resolveServerVersion(): string | undefined {
281
283
  }
282
284
  }
283
285
 
286
+ // Bind MJStorage as the conversation-attachment blob store. The attachment service itself no longer
287
+ // imports `@memberjunction/storage` — that dependency made it unusable from any browser or React
288
+ // Native client, which is why the same attachment rules had been reimplemented three times.
289
+ //
290
+ // This runs at module load, not inside `serve()`, so that merely importing MJServer is enough: any
291
+ // entry point that reaches the attachment service — a resolver under test, a script, a worker that
292
+ // never calls `serve()` — finds storage already bound rather than degrading to "storage is not
293
+ // available on this host". The store is stateless and configures `FileStorageEngine` on use, so
294
+ // there is no ordering hazard in binding this early.
295
+ GetAttachmentService().BlobStore = new MJStorageBlobStore();
296
+
284
297
  export const serve = async (resolverPaths: Array<string>, app: Application = createApp(), options?: MJServerOptions): Promise<void> => {
285
298
  const t0 = performance.now();
286
299
  // Level-gated startup logger. Resolves verbosity from telemetry.level (single
@@ -307,7 +320,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
307
320
  console.log({ combinedResolverPaths, paths, cwd: process.cwd() });
308
321
  }
309
322
 
310
- const setupComplete$ = new ReplaySubject(1);
323
+ const setupComplete$ = new ReplaySubject(1);
311
324
  const dbType = getDbType();
312
325
  const dataSources: DataSourceInfo[] = [];
313
326
 
@@ -5,7 +5,7 @@ import { MJEntityDocumentEntity, MJVectorIndexEntity, MJVectorDatabaseEntity } f
5
5
  import { ResolverBase } from '../generic/ResolverBase.js';
6
6
  import { GetAIAPIKey } from '@memberjunction/ai';
7
7
  import { VectorDBBase } from '@memberjunction/ai-vectordb';
8
- import { MJGlobal, UUIDsEqual } from '@memberjunction/global';
8
+ import { EscapeSQLString, MJGlobal, UUIDsEqual } from '@memberjunction/global';
9
9
 
10
10
  /* ───── GraphQL types ───── */
11
11
 
@@ -144,7 +144,7 @@ export class FetchEntityVectorsResolver extends ResolverBase {
144
144
  const rv = new RunView();
145
145
  const result = await rv.RunView<MJEntityDocumentEntity>({
146
146
  EntityName: 'MJ: Entity Documents',
147
- ExtraFilter: `ID='${entityDocumentID}'`,
147
+ ExtraFilter: `ID='${EscapeSQLString(entityDocumentID)}'`,
148
148
  ResultType: 'entity_object',
149
149
  }, contextUser);
150
150
 
@@ -6,6 +6,7 @@ import { MJQueryResolver, MJQuery_, MJQueryField_, MJQueryParameter_, MJQueryEnt
6
6
  import { GetReadWriteProvider } from '../util.js';
7
7
  import { DeleteOptionsInput } from '../generic/DeleteOptionsInput.js';
8
8
  import { MJQueryEntityServer } from '@memberjunction/core-entities-server';
9
+ import { EscapeSQLString } from '@memberjunction/global';
9
10
 
10
11
  /**
11
12
  * Minimal shape of a query row returned by RunView lookups (plain object, not entity instance).
@@ -493,7 +494,7 @@ export class MJQueryResolverExtended extends MJQueryResolver {
493
494
  // now make sure there is NO existing query by the same name in the specified category
494
495
  const existingQueryResult = await provider.RunView({
495
496
  EntityName: 'MJ: Queries',
496
- ExtraFilter: `Name='${input.Name}' AND CategoryID='${finalCategoryID}'`
497
+ ExtraFilter: `Name='${EscapeSQLString(input.Name)}' AND CategoryID='${EscapeSQLString(finalCategoryID)}'`
497
498
  }, context.userPayload.userRecord);
498
499
  if (existingQueryResult.Success && existingQueryResult.Results?.length > 0) {
499
500
  // we have a match! Let's return an error
@@ -57,7 +57,7 @@ import {
57
57
  deleteRealtimeRecordingSegments,
58
58
  } from '@memberjunction/ai-agents';
59
59
  import { AgentExecutionProgressCallback, MJAIAgentEntityExtended, AppContextSnapshot } from '@memberjunction/ai-core-plus';
60
- import { RealtimeToolDefinition } from '@memberjunction/ai';
60
+ import { ChatMessage, RealtimeToolDefinition } from '@memberjunction/ai';
61
61
  import { ResolverBase } from '../generic/ResolverBase.js';
62
62
  import { PUSH_STATUS_UPDATES_TOPIC } from '../generic/PushStatusResolver.js';
63
63
  import { GetReadWriteProvider } from '../util.js';
@@ -136,6 +136,11 @@ const MAX_PRIOR_TRANSCRIPT_CHARS = 8_000;
136
136
  /** Maximum prior-session chain legs walked when hydrating a resumed session's transcript. */
137
137
  const MAX_PRIOR_TRANSCRIPT_LEGS = 5;
138
138
 
139
+ /** Maximum TEXT-conversation turns hydrated into a voice session's companion prompt (newest kept). */
140
+ const MAX_CONVERSATION_HISTORY_TURNS = 30;
141
+ /** Maximum TEXT-conversation CHARS hydrated into a voice session's companion prompt (oldest dropped). */
142
+ const MAX_CONVERSATION_HISTORY_CHARS = 8_000;
143
+
139
144
  /**
140
145
  * Authoritative shape persisted in `AIAgentSession.Config_` for a client-direct voice session.
141
146
  * The target agent id is stored here at start and read back on every relay — the browser never
@@ -486,10 +491,17 @@ export class RealtimeClientSessionResolver extends ResolverBase {
486
491
  // Best-effort model-context hydration: the PRIOR session chain's transcript (ownership-
487
492
  // checked, capped) is framed into the system prompt so the model REMEMBERS the last leg.
488
493
  // Strictly tolerant — any problem yields no hydration, never a failed start.
489
- const priorTranscript = await this.loadPriorTranscript(lastSessionId, contextUser, provider);
494
+ const prior = await this.loadPriorTranscript(lastSessionId, contextUser, provider);
495
+ // ...and the TEXT conversation the call is being started from, so a voice session opened
496
+ // mid-thread knows what was already typed. The legs above are excluded: they are already
497
+ // framed as the resumed transcript, and injecting the same turns twice puts two accounts of
498
+ // the same exchange in one prompt.
499
+ const conversationMessages = await this.loadConversationHistory(
500
+ conversationId, prior?.LegIDs ?? [], contextUser, provider,
501
+ );
490
502
  const result = await this.prepareClientSessionOrClose(
491
- session, coAgentID, effectiveTargetId, contextUser, provider, preferredModelId, clientTools, priorTranscript,
492
- configOverridesJson, maxSessionSeconds, applicationId, this.parseAppContext(appContextJson),
503
+ session, coAgentID, effectiveTargetId, contextUser, provider, preferredModelId, clientTools, prior?.Text,
504
+ configOverridesJson, maxSessionSeconds, applicationId, this.parseAppContext(appContextJson), conversationMessages,
493
505
  );
494
506
  // Best-effort restore of the PRIOR session's persisted channel states (e.g. the whiteboard
495
507
  // board). Strictly tolerant — any problem yields a null field, never a failed start.
@@ -1684,6 +1696,8 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1684
1696
  * @param priorTranscript Optional capped, role-tagged transcript of the PRIOR session chain
1685
1697
  * (from {@link loadPriorTranscript}) — the service frames it into the system prompt so a
1686
1698
  * resumed session remembers the previous leg(s).
1699
+ * @param conversationMessages Optional capped history of the TEXT conversation this session is
1700
+ * starting from (from {@link loadConversationHistory}) — framed as "Conversation so far".
1687
1701
  */
1688
1702
  private async prepareClientSessionOrClose(
1689
1703
  session: MJAIAgentSessionEntity,
@@ -1698,6 +1712,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1698
1712
  maxSessionSeconds?: number,
1699
1713
  applicationId?: string,
1700
1714
  appContext?: AppContextSnapshot,
1715
+ conversationMessages?: ChatMessage[],
1701
1716
  ): Promise<StartRealtimeClientSessionResult> {
1702
1717
  const prep = await this.clientSessionService.PrepareClientSession(
1703
1718
  {
@@ -1708,10 +1723,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1708
1723
  // Server-authoritative voice duration cap (widget guests) — bounds the driver session
1709
1724
  // where supported; the janitor enforces the session deadline regardless.
1710
1725
  MaxSessionSeconds: maxSessionSeconds,
1711
- // MVP: conversation history is not yet hydrated into ChatMessage[]; the co-agent
1712
- // companion prompt runs without prior turns. A later phase loads the session's
1713
- // Conversation into ChatMessage[] for richer context.
1714
- ConversationMessages: [],
1726
+ // The TEXT conversation this call was started from, so the co-agent opens knowing
1727
+ // what the user already typed rather than asking them to say it again. Hydrated by
1728
+ // `loadConversationHistory`; empty when there is no conversation or the read failed.
1729
+ ConversationMessages: conversationMessages ?? [],
1715
1730
  UserID: contextUser.ID,
1716
1731
  PreferredModelID: preferredModelId,
1717
1732
  // Client-declared, CLIENT-EXECUTED UI tools (see the mutation's SECURITY NOTE) —
@@ -2005,7 +2020,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2005
2020
  lastSessionId: string | undefined,
2006
2021
  contextUser: UserInfo,
2007
2022
  provider: IMetadataProvider,
2008
- ): Promise<string | undefined> {
2023
+ ): Promise<{ Text: string; LegIDs: string[] } | undefined> {
2009
2024
  if (!lastSessionId) {
2010
2025
  return undefined;
2011
2026
  }
@@ -2016,7 +2031,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2016
2031
  }
2017
2032
  const turns = await this.loadChainTranscriptTurns(legIDs, contextUser, provider);
2018
2033
  const lines = this.capTranscriptLines(turns);
2019
- return lines.length > 0 ? lines.join('\n') : undefined;
2034
+ // The leg ids travel with the text because the CONVERSATION history hydrated alongside
2035
+ // this would otherwise re-inject the same turns: a voice turn is persisted as an
2036
+ // ordinary Conversation Detail, so these legs are part of the conversation too.
2037
+ return lines.length > 0 ? { Text: lines.join('\n'), LegIDs: legIDs } : undefined;
2020
2038
  } catch (error) {
2021
2039
  LogError(
2022
2040
  `StartRealtimeClientSession: prior-transcript hydration failed for session ${lastSessionId}: ${(error as Error).message}`,
@@ -2025,6 +2043,101 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2025
2043
  }
2026
2044
  }
2027
2045
 
2046
+ /**
2047
+ * Loads the conversation this voice session is starting from, as `ChatMessage[]` for the
2048
+ * co-agent's companion prompt.
2049
+ *
2050
+ * This is the half of the context flow that was missing. Voice turns have always been
2051
+ * persisted as `MJ: Conversation Details`, so anything SAID reaches a later text turn — but
2052
+ * nothing TYPED reached a voice session, which opened with no idea what the thread was about.
2053
+ * The symptom is not an error; it is the agent asking the user to repeat something they just
2054
+ * wrote, which is exactly when voice is least forgivable.
2055
+ *
2056
+ * Caps mirror the resume path (newest {@link MAX_CONVERSATION_HISTORY_TURNS} turns, then a
2057
+ * {@link MAX_CONVERSATION_HISTORY_CHARS} budget with the oldest dropped first) so the freshest
2058
+ * context always survives.
2059
+ *
2060
+ * Session-stamped rows are KEPT unless `coveredSessionIDs` names them: a call the user had last
2061
+ * week is part of this conversation and the model should know it. Only the legs already framed
2062
+ * as the resumed transcript are removed, so the prompt never carries two accounts of one
2063
+ * exchange.
2064
+ *
2065
+ * Strictly tolerant — no conversation, a failed read, or any throw yields `[]`, never a failed
2066
+ * session start. The call still works; it just starts cold.
2067
+ *
2068
+ * @param conversationId The conversation the session is bound to, when there is one.
2069
+ * @param coveredSessionIDs Session ids already framed via `PriorTranscript`.
2070
+ * @param contextUser The calling user (RunView scope — the read is row-level-secured).
2071
+ * @param provider The request-scoped metadata provider.
2072
+ * @returns The capped history, oldest first.
2073
+ */
2074
+ private async loadConversationHistory(
2075
+ conversationId: string | undefined,
2076
+ coveredSessionIDs: string[],
2077
+ contextUser: UserInfo,
2078
+ provider: IMetadataProvider,
2079
+ ): Promise<ChatMessage[]> {
2080
+ if (!conversationId) {
2081
+ return [];
2082
+ }
2083
+ try {
2084
+ const rv = RunView.FromMetadataProvider(provider);
2085
+ const result = await rv.RunView<{
2086
+ Role: string;
2087
+ Message: string | null;
2088
+ HiddenToUser: boolean;
2089
+ AgentSessionID: string | null;
2090
+ }>(
2091
+ {
2092
+ EntityName: CONVERSATION_DETAIL_ENTITY,
2093
+ ExtraFilter: `ConversationID='${conversationId.replace(/'/g, "''")}'`,
2094
+ Fields: ['ID', 'Role', 'Message', 'HiddenToUser', 'AgentSessionID', '__mj_CreatedAt'],
2095
+ OrderBy: '__mj_CreatedAt ASC',
2096
+ ResultType: 'simple',
2097
+ },
2098
+ contextUser,
2099
+ );
2100
+ if (!result.Success) {
2101
+ LogError(`StartRealtimeClientSession: conversation-history query failed: ${result.ErrorMessage}`);
2102
+ return [];
2103
+ }
2104
+ const covered = new Set(coveredSessionIDs.map((id) => id.trim().toLowerCase()));
2105
+ const turns = (result.Results ?? []).filter(
2106
+ (row) =>
2107
+ !row.HiddenToUser &&
2108
+ (row.Role === 'User' || row.Role === 'AI') &&
2109
+ typeof row.Message === 'string' &&
2110
+ row.Message.trim().length > 0 &&
2111
+ !covered.has((row.AgentSessionID ?? '').trim().toLowerCase()),
2112
+ );
2113
+ return this.capConversationHistory(turns);
2114
+ } catch (error) {
2115
+ LogError(
2116
+ `StartRealtimeClientSession: conversation-history hydration failed for conversation ${conversationId}: ${(error as Error).message}`,
2117
+ );
2118
+ return [];
2119
+ }
2120
+ }
2121
+
2122
+ /**
2123
+ * Applies the history caps and maps to `ChatMessage[]`: the newest
2124
+ * {@link MAX_CONVERSATION_HISTORY_TURNS} turns, then a total budget of
2125
+ * {@link MAX_CONVERSATION_HISTORY_CHARS} characters with the oldest dropped first.
2126
+ */
2127
+ private capConversationHistory(turns: Array<{ Role: string; Message: string | null }>): ChatMessage[] {
2128
+ const newest = turns.slice(-MAX_CONVERSATION_HISTORY_TURNS);
2129
+ const messages: ChatMessage[] = newest.map((t) => ({
2130
+ role: t.Role === 'AI' ? 'assistant' : 'user',
2131
+ content: (t.Message ?? '').trim(),
2132
+ }));
2133
+ let total = messages.reduce((sum, m) => sum + String(m.content).length + 1, 0);
2134
+ while (messages.length > 0 && total > MAX_CONVERSATION_HISTORY_CHARS) {
2135
+ const dropped = messages.shift() as ChatMessage;
2136
+ total -= String(dropped.content).length + 1;
2137
+ }
2138
+ return messages;
2139
+ }
2140
+
2028
2141
  /**
2029
2142
  * Walks the prior-session chain backwards from `lastSessionId`, returning the OWNED leg ids
2030
2143
  * (newest first). The first leg must exist and be owned by the caller, else `[]` (logged);
@@ -1631,12 +1631,13 @@ export class RunAIAgentResolver extends ResolverBase {
1631
1631
  if (!artifactVersion.FileID) return null;
1632
1632
 
1633
1633
  try {
1634
- // Use the attachment service's downloadFileContent which uses GetObject directly
1634
+ // The attachment service returns base64 rather than a Buffer — `Buffer` is Node-only,
1635
+ // and removing it from that signature is what let the service stop depending on the
1636
+ // storage SDKs and become usable from browser and React Native hosts.
1635
1637
  const attachmentService = GetAttachmentService();
1636
- const buffer = await attachmentService.DownloadFileContent(artifactVersion.FileID, contextUser, provider);
1637
- if (!buffer) return null;
1638
+ const base64 = await attachmentService.DownloadFileContent(artifactVersion.FileID, contextUser, provider);
1639
+ if (!base64) return null;
1638
1640
 
1639
- const base64 = buffer.toString('base64');
1640
1641
  const mimeType = artifactVersion.MimeType || 'application/octet-stream';
1641
1642
  return `data:${mimeType};base64,${base64}`;
1642
1643
  } catch (err) {
@@ -114,7 +114,9 @@ export class SearchEntitiesResolver {
114
114
  Groups: params.map(p => ({ EntityName: p.EntityName, Results: [] })),
115
115
  };
116
116
  }
117
- const md = GetReadOnlyProvider(providers);
117
+ // Search is a pure read, so the read-write pool is a valid fallback. Without it, a server
118
+ // with no read-only login configured gets `null` here and every search silently returns empty.
119
+ const md = GetReadOnlyProvider(providers, { allowFallbackToReadWrite: true });
118
120
  const user = UserCache.Instance.Users.find(
119
121
  (u) => u.Email.trim().toLowerCase() === userPayload.email.trim().toLowerCase()
120
122
  );
@@ -32,6 +32,9 @@ export class SearchKnowledgeResultItem {
32
32
  @Field()
33
33
  EntityName: string;
34
34
 
35
+ @Field({ nullable: true })
36
+ EntityDisplayName?: string;
37
+
35
38
  @Field()
36
39
  RecordID: string;
37
40
 
@@ -461,6 +464,7 @@ export class SearchKnowledgeResolver extends ResolverBase {
461
464
  Results: result.Results.map((r: SearchEngineResultItem) => ({
462
465
  ID: r.ID,
463
466
  EntityName: r.EntityName,
467
+ EntityDisplayName: r.EntityDisplayName,
464
468
  RecordID: r.RecordID,
465
469
  SourceType: r.SourceType,
466
470
  ResultType: r.ResultType,
@@ -88,6 +88,7 @@ export class SearchKnowledgeSystemUserResolver extends ResolverBase {
88
88
  Results: result.Results.map((r: SearchEngineResultItem) => ({
89
89
  ID: r.ID,
90
90
  EntityName: r.EntityName,
91
+ EntityDisplayName: r.EntityDisplayName,
91
92
  RecordID: r.RecordID,
92
93
  SourceType: r.SourceType,
93
94
  ResultType: r.ResultType,
@@ -78,6 +78,19 @@ export class TransactionOutputType {
78
78
 
79
79
 
80
80
 
81
+ /**
82
+ * Renders one refused transaction-group item for the server log, naming the position, entity and
83
+ * operation so an operator can tie the refusal back to the row the client sent.
84
+ *
85
+ * `CompleteMessage` rather than `Message` because a refusal can report itself through any of
86
+ * `Message`, `Error` or the `Errors` array — a validation failure populates only the last of the
87
+ * three, so reading `Message` alone logs an empty reason for exactly the case that matters most.
88
+ */
89
+ function describeRefusedItem(index: number, item: TransactionItemInputType, entity: BaseEntity): string {
90
+ const reason = entity.LatestResult?.CompleteMessage?.trim();
91
+ return ` [${index}] ${item.OperationType} '${item.EntityName}': ${reason || 'refused without a reported reason'}`;
92
+ }
93
+
81
94
  export class TransactionResolver extends ResolverBase {
82
95
  /**
83
96
  * Maps a TransactionGroup item's operation type onto the SAME API-key scope path the singular
@@ -123,8 +136,14 @@ export class TransactionResolver extends ResolverBase {
123
136
  const tg = await md.CreateTransactionGroup();
124
137
  const entityObjects: BaseEntity[] = [];
125
138
  const objectValues: any[] = [];
139
+ // #4309: Save()/Delete() report a LOGICAL refusal by returning false — they do not throw —
140
+ // and a refused row is never enrolled, because TransactionGroup.AddTransaction() is reached
141
+ // only from inside ProviderToUse.Save()/Delete(). Discarding that boolean let a group whose
142
+ // rows were all refused arrive at Submit() empty, take its legitimate "nothing to do"
143
+ // branch, and report success for writes that never happened.
144
+ const refusals: string[] = [];
126
145
 
127
- for (const item of group.Items) {
146
+ for (const [index, item] of group.Items.entries()) {
128
147
  // instantiate a new entity object for the item
129
148
  const entity = await md.GetEntityObject(item.EntityName, context.userPayload.userRecord);
130
149
  entityObjects.push(entity); // save for later for mapping variables if needed
@@ -147,17 +166,51 @@ export class TransactionResolver extends ResolverBase {
147
166
  objectValues.push(itemValues);
148
167
  entity.SetMany(itemValues, true);
149
168
  entity.TransactionGroup = tg;
150
- await entity.Save();
169
+ if (!await entity.Save()) {
170
+ refusals.push(describeRefusedItem(index, item, entity));
171
+ }
151
172
  break;
152
173
  case "Delete":
153
174
  await entity.InnerLoad(pkey);
154
- objectValues.push(entity.GetDataObject());
175
+ // AWAITED: GetDataObject() is async, and pushing the un-awaited Promise put
176
+ // one into `objectValues` — which is `any[]`, so neither tsc nor a
177
+ // floating-promise lint could see it. PrepareReturnValue's Delete branch
178
+ // then stringified that Promise, and JSON.stringify(Promise) is "{}", so
179
+ // EVERY Delete in a transaction group reported an empty payload, successful
180
+ // ones included. Worse than losing the payload: GraphQLDataProvider's Delete
181
+ // transaction callback validates the commit with
182
+ // `pk.Value !== results[pk.FieldName]`, so "{}" made every key mismatch and
183
+ // reported 'Transaction failed to commit' for a delete that DID commit.
184
+ // Awaiting costs nothing here — DataObjectParams defaults
185
+ // includeRelatedEntityData to false, which guards the method's only awaited
186
+ // work, so with no params this is GetAll() plus a filter loop.
187
+ objectValues.push(await entity.GetDataObject());
155
188
  entity.TransactionGroup = tg;
156
- await entity.Delete();
189
+ if (!await entity.Delete()) {
190
+ refusals.push(describeRefusedItem(index, item, entity));
191
+ }
157
192
  break;
158
193
  }
159
194
  }
160
195
 
196
+ // A refused row never enrolled, so submitting now would commit only the SURVIVORS and
197
+ // still report unqualified success. Nothing has been written at this point — enrolment
198
+ // is deferral, and the provider does not touch the database until Submit() — so
199
+ // returning here leaves the database exactly as we found it.
200
+ //
201
+ // The predicate is the RETURN VALUE, not whether the group ended up empty: a row that
202
+ // is not dirty also fails to enrol and correctly returns true, and an empty group
203
+ // legitimately means "nothing to do" for a caller that enrolled nothing. That is also
204
+ // why this belongs here rather than in TransactionGroupBase.Submit() — this is the only
205
+ // layer that still knows which row was refused and why.
206
+ if (refusals.length > 0) {
207
+ LogError(
208
+ `TransactionResolver::ExecuteTransactionGroup --- ${refusals.length} of ${group.Items.length} ` +
209
+ `item(s) were refused, so the group was not submitted:\n${refusals.join('\n')}`
210
+ );
211
+ return await this.PrepareReturnValue(false, entityObjects, objectValues, group);
212
+ }
213
+
161
214
  // now, we need to set the variables
162
215
  if (group.Variables && group.Variables.length > 0) {
163
216
  for (const networkVar of group.Variables) {