@memberjunction/server 6.2.0-edge.1 → 6.2.0-edge.2

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/dist/.tsbuildinfo +1 -1
  2. package/dist/generated/graphql-schemas/__mj.d.ts +1010 -0
  3. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  4. package/dist/generated/graphql-schemas/__mj.js +5178 -3
  5. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  6. package/dist/generic/FireAndForgetHeartbeat.d.ts +15 -1
  7. package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -1
  8. package/dist/generic/FireAndForgetHeartbeat.js +16 -2
  9. package/dist/generic/FireAndForgetHeartbeat.js.map +1 -1
  10. package/dist/generic/OrphanedConversationDetailReconciler.d.ts +31 -0
  11. package/dist/generic/OrphanedConversationDetailReconciler.d.ts.map +1 -0
  12. package/dist/generic/OrphanedConversationDetailReconciler.js +215 -0
  13. package/dist/generic/OrphanedConversationDetailReconciler.js.map +1 -0
  14. package/dist/generic/PushStatusResolver.d.ts +38 -0
  15. package/dist/generic/PushStatusResolver.d.ts.map +1 -1
  16. package/dist/generic/PushStatusResolver.js +84 -1
  17. package/dist/generic/PushStatusResolver.js.map +1 -1
  18. package/dist/generic/ResolverBase.d.ts +13 -1
  19. package/dist/generic/ResolverBase.d.ts.map +1 -1
  20. package/dist/generic/ResolverBase.js +49 -47
  21. package/dist/generic/ResolverBase.js.map +1 -1
  22. package/dist/index.d.ts +3 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +90 -2
  25. package/dist/index.js.map +1 -1
  26. package/dist/resolvers/AgentRunStatusPublisher.d.ts +55 -0
  27. package/dist/resolvers/AgentRunStatusPublisher.d.ts.map +1 -0
  28. package/dist/resolvers/AgentRunStatusPublisher.js +141 -0
  29. package/dist/resolvers/AgentRunStatusPublisher.js.map +1 -0
  30. package/dist/resolvers/ConversationRunEventsResolver.d.ts +90 -0
  31. package/dist/resolvers/ConversationRunEventsResolver.d.ts.map +1 -0
  32. package/dist/resolvers/ConversationRunEventsResolver.js +283 -0
  33. package/dist/resolvers/ConversationRunEventsResolver.js.map +1 -0
  34. package/dist/resolvers/DuplicateEntryCheckResolver.d.ts +98 -0
  35. package/dist/resolvers/DuplicateEntryCheckResolver.d.ts.map +1 -0
  36. package/dist/resolvers/DuplicateEntryCheckResolver.js +227 -0
  37. package/dist/resolvers/DuplicateEntryCheckResolver.js.map +1 -0
  38. package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
  39. package/dist/resolvers/FetchEntityVectorsResolver.js +2 -1
  40. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  41. package/dist/resolvers/MergeRecordsResolver.d.ts +7 -0
  42. package/dist/resolvers/MergeRecordsResolver.d.ts.map +1 -1
  43. package/dist/resolvers/MergeRecordsResolver.js +16 -2
  44. package/dist/resolvers/MergeRecordsResolver.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 +0 -16
  50. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  51. package/dist/resolvers/RunAIAgentResolver.js +23 -153
  52. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  53. package/dist/resolvers/RunDecisionResolver.d.ts +149 -0
  54. package/dist/resolvers/RunDecisionResolver.d.ts.map +1 -0
  55. package/dist/resolvers/RunDecisionResolver.js +396 -0
  56. package/dist/resolvers/RunDecisionResolver.js.map +1 -0
  57. package/dist/resolvers/VectorizeEntityResolver.d.ts +1 -1
  58. package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
  59. package/dist/resolvers/VectorizeEntityResolver.js +14 -10
  60. package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
  61. package/package.json +94 -92
  62. package/src/__tests__/AgentRunStatusPublisher.test.ts +175 -0
  63. package/src/__tests__/DuplicateEntryCheckResolver.test.ts +300 -0
  64. package/src/__tests__/RealtimeClientSessionResolver.test.ts +216 -2
  65. package/src/__tests__/RemoteBrowserActionRecovery.test.ts +7 -1
  66. package/src/__tests__/RemoteBrowserAudioStream.test.ts +7 -1
  67. package/src/__tests__/RemoteBrowserSnapshot.test.ts +7 -1
  68. package/src/__tests__/ResolverBase.clientOldValues.test.ts +52 -0
  69. package/src/__tests__/RunAIAgentResolver.historyFrom.test.ts +182 -12
  70. package/src/__tests__/RunAIAgentResolver.streaming.test.ts +18 -27
  71. package/src/__tests__/RunDecisionResolver.test.ts +699 -0
  72. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +2 -0
  73. package/src/__tests__/VectorizeEntityResolver.test.ts +106 -0
  74. package/src/__tests__/conversationRunEvents.test.ts +399 -0
  75. package/src/__tests__/orphanedConversationDetailReconciler.test.ts +283 -0
  76. package/src/__tests__/pushStatusFanOut.test.ts +206 -0
  77. package/src/auth/__tests__/backward-compatibility.test.ts +1 -0
  78. package/src/generated/graphql-schemas/__mj.ts +3637 -4
  79. package/src/generic/FireAndForgetHeartbeat.ts +16 -2
  80. package/src/generic/OrphanedConversationDetailReconciler.ts +267 -0
  81. package/src/generic/PushStatusResolver.ts +97 -1
  82. package/src/generic/ResolverBase.ts +49 -49
  83. package/src/index.ts +102 -2
  84. package/src/resolvers/AgentRunStatusPublisher.ts +175 -0
  85. package/src/resolvers/ConversationRunEventsResolver.ts +308 -0
  86. package/src/resolvers/DuplicateEntryCheckResolver.ts +232 -0
  87. package/src/resolvers/FetchEntityVectorsResolver.ts +2 -1
  88. package/src/resolvers/MergeRecordsResolver.ts +14 -2
  89. package/src/resolvers/RealtimeClientSessionResolver.ts +93 -35
  90. package/src/resolvers/RunAIAgentResolver.ts +28 -174
  91. package/src/resolvers/RunDecisionResolver.ts +435 -0
  92. package/src/resolvers/VectorizeEntityResolver.ts +16 -11
  93. package/src/resolvers/__tests__/MergeRecordsResolver.test.ts +93 -0
@@ -2,8 +2,22 @@ import { PubSubEngine } from 'type-graphql';
2
2
  import { LogError } from '@memberjunction/core';
3
3
  import { PublishStatusUpdate } from './PushStatusResolver.js';
4
4
 
5
- /** Default cadence for fire-and-forget liveness pulses (5 minutes). */
6
- export const DEFAULT_PULSE_INTERVAL_MS = 5 * 60 * 1000;
5
+ /**
6
+ * Default cadence for fire-and-forget liveness pulses (60 seconds).
7
+ *
8
+ * MATCHED PAIR — do not change alone. The client's inactivity window
9
+ * (`DEFAULT_IDLE_TIMEOUT_MS` in GraphQLDataProvider's fireAndForgetHelper) is sized at roughly
10
+ * 3x this value, so a healthy run always refreshes the timer well before it expires. Lengthening
11
+ * this without lengthening that window makes every quiet stretch of a long agent run look like a
12
+ * stall and trips reconciliation; shortening the window without shortening this does the same.
13
+ *
14
+ * WAS 5 MINUTES (MJ #4222). Paired with the old 12-minute window, a client whose transport had
15
+ * silently died waited 12 minutes to notice — on runs that typically finish in 20 seconds. The
16
+ * pulse is a few dozen bytes and exists only while an operation is actually running, so the
17
+ * added traffic is negligible: a 40-minute run now sends ~40 pulses instead of ~8, and the
18
+ * 20-second run that motivated the change still sends none.
19
+ */
20
+ export const DEFAULT_PULSE_INTERVAL_MS = 60 * 1000;
7
21
 
8
22
  /** The `type` discriminator carried by liveness pulse messages. */
9
23
  export const HEARTBEAT_MESSAGE_TYPE = 'Heartbeat';
@@ -0,0 +1,267 @@
1
+ import { LogError, LogStatus, RunView } from '@memberjunction/core';
2
+ import { UUIDsEqual } from '@memberjunction/global';
3
+ import { UserCache } from '@memberjunction/sqlserver-dataprovider';
4
+ import type { IMetadataProvider, UserInfo } from '@memberjunction/core';
5
+ import type { MJAIAgentRunEntity, MJConversationDetailEntity } from '@memberjunction/core-entities';
6
+
7
+ /**
8
+ * Grace period after a run reaches a terminal state before its conversation detail is considered
9
+ * abandoned. The normal path closes the detail moments after the run finishes; this waits well past
10
+ * that so a reconcile can never race a completion that is already on its way.
11
+ */
12
+ export const ORPHAN_DETAIL_GRACE_MS = 2 * 60 * 1000;
13
+
14
+ /** Ceiling on details closed per pass, so a large backlog cannot stall startup. */
15
+ const MAX_DETAILS_PER_PASS = 200;
16
+
17
+ /**
18
+ * Agent-run statuses that mean the run succeeded. `AwaitingFeedback` is the normal end of a chat
19
+ * turn: the agent replied and the next move is the user's.
20
+ */
21
+ const SUCCESSFUL_RUN_STATUSES: MJAIAgentRunEntity['Status'][] = ['Completed', 'AwaitingFeedback'];
22
+
23
+ /**
24
+ * Agent-run statuses that mean execution is over, whatever the outcome. `Paused` is absent: the
25
+ * run is parked on a workflow that is still executing.
26
+ */
27
+ const TERMINAL_RUN_STATUSES: MJAIAgentRunEntity['Status'][] = [...SUCCESSFUL_RUN_STATUSES, 'Failed', 'Cancelled'];
28
+
29
+ /**
30
+ * Close conversation details left `In-Progress` by a run that is already over (MJ #4222).
31
+ *
32
+ * WHY THIS EXISTS. `AgentRunner` closes the detail as the final step of a run, so a process that
33
+ * dies mid-run never reaches it. The agent-run watchdog repairs the RUN — force-failing it once the
34
+ * heartbeat goes stale — but nothing repairs the detail, and the detail is the row the chat renders
35
+ * from. The result is durable state that disagrees with itself: a terminal run beside a message
36
+ * that still says it is being generated, spinning forever for anyone who opens it.
37
+ *
38
+ * WHY NOT IN THE SWEEP PROC. `spSweepStaleAIAgentRuns` is a single set-based UPDATE, and extending
39
+ * it to the detail would be the obvious fix. It would also be the wrong one: `MJ: Conversation
40
+ * Details` declares `TrustServerCacheCompletely`, so the server's RunView cache relies on
41
+ * `BaseEntity` events for invalidation and a stored procedure fires none. The row would change
42
+ * while every instance kept serving `In-Progress` from cache — a worse failure than the one being
43
+ * fixed, because reconciliation itself would read the stale value back. Going through
44
+ * `BaseEntity.Save()` costs a per-row write and buys correct invalidation everywhere.
45
+ *
46
+ * Idempotent and safe to run concurrently on several instances: the write is a no-op once the
47
+ * status is terminal, and each pass re-reads state before deciding.
48
+ *
49
+ * @returns How many details were closed.
50
+ */
51
+ export async function ReconcileOrphanedConversationDetails(
52
+ provider: IMetadataProvider,
53
+ contextUser: UserInfo,
54
+ graceMs: number = ORPHAN_DETAIL_GRACE_MS
55
+ ): Promise<number> {
56
+ try {
57
+ const rv = RunView.FromMetadataProvider(provider);
58
+
59
+ const detailResult = await rv.RunView<MJConversationDetailEntity>(
60
+ {
61
+ EntityName: 'MJ: Conversation Details',
62
+ ExtraFilter: `Status = 'In-Progress' AND Role = 'AI'${hasTerminalRunClause(provider)}`,
63
+ OrderBy: '__mj_CreatedAt ASC',
64
+ MaxRows: MAX_DETAILS_PER_PASS,
65
+ ResultType: 'entity_object',
66
+ },
67
+ contextUser
68
+ );
69
+ if (!detailResult.Success || !detailResult.Results?.length) {
70
+ return 0;
71
+ }
72
+
73
+ const runsByDetail = await loadTerminalRuns(rv, detailResult.Results.map(d => d.ID), contextUser);
74
+ if (runsByDetail.size === 0) {
75
+ return 0;
76
+ }
77
+
78
+ const ownersByConversation = await loadConversationOwners(
79
+ rv,
80
+ detailResult.Results.map(d => d.ConversationID).filter((id): id is string => !!id),
81
+ contextUser
82
+ );
83
+
84
+ const cutoff = Date.now() - graceMs;
85
+ let closed = 0;
86
+
87
+ for (const detail of detailResult.Results) {
88
+ const run = runsByDetail.get(detail.ID);
89
+ if (!run) {
90
+ // No run yet, or its run is still executing. Both are legitimately in progress.
91
+ continue;
92
+ }
93
+ const completedAt = run.CompletedAt ? new Date(run.CompletedAt).getTime() : NaN;
94
+ if (!Number.isFinite(completedAt) || completedAt > cutoff) {
95
+ // Inside the grace window — the owning process may still be closing this itself.
96
+ continue;
97
+ }
98
+
99
+ // Write as the conversation's OWNER, not as the system user.
100
+ //
101
+ // `MJConversationDetailEntityExtended.Save()` gates writes on conversation ownership: a
102
+ // non-owner without a resource grant is refused, returning false with no result
103
+ // registered anywhere — indistinguishable from a crash, and the reason this pass
104
+ // silently wrote nothing at all until it was traced. The system user owns no
105
+ // conversations, so it is exactly the caller that gate exists to stop. Saving as the
106
+ // owner satisfies the gate on its own terms rather than routing around it, and keeps
107
+ // the write attributable to the person whose message it is.
108
+ const owner = detail.ConversationID ? ownersByConversation.get(detail.ConversationID) : undefined;
109
+ if (!owner) {
110
+ LogError(`[OrphanDetailReconciler] No owner resolved for conversation ${detail.ConversationID}; leaving detail ${detail.ID}`);
111
+ continue;
112
+ }
113
+ const writable = await provider.GetEntityObject<MJConversationDetailEntity>('MJ: Conversation Details', owner);
114
+ if (!(await writable.Load(detail.ID))) {
115
+ LogError(`[OrphanDetailReconciler] Could not load detail ${detail.ID} for write`);
116
+ continue;
117
+ }
118
+ if (writable.Status !== 'In-Progress') {
119
+ // Closed by something else since the list query. Its outcome stands.
120
+ continue;
121
+ }
122
+ // The failure marker belongs only to the error branch. A completed run has nothing to
123
+ // report, so an empty message stays empty rather than contradicting its own status.
124
+ if (SUCCESSFUL_RUN_STATUSES.includes(run.Status)) {
125
+ writable.Status = 'Complete';
126
+ } else {
127
+ writable.Status = 'Error';
128
+ if (!writable.Message) {
129
+ writable.Message = run.ErrorMessage ?? '❌ Failed';
130
+ }
131
+ }
132
+
133
+ if (await writable.Save()) {
134
+ closed++;
135
+ } else {
136
+ const result = writable.LatestResult;
137
+ LogError(
138
+ `[OrphanDetailReconciler] Failed to close detail ${detail.ID}: ` +
139
+ `${result?.CompleteMessage || result?.Message || 'no message'}`
140
+ );
141
+ }
142
+ }
143
+
144
+ if (closed > 0) {
145
+ LogStatus(`[OrphanDetailReconciler] Closed ${closed} conversation detail(s) left behind by finished runs`);
146
+ }
147
+ return closed;
148
+ } catch (err) {
149
+ // Maintenance work must never take the server down with it.
150
+ LogError(`[OrphanDetailReconciler] Pass failed: ${err instanceof Error ? err.message : String(err)}`);
151
+ return 0;
152
+ }
153
+ }
154
+
155
+ /** The quoting a database provider offers. Present on every server-side provider. */
156
+ interface QuotingProvider {
157
+ QuoteIdentifier(name: string): string;
158
+ QuoteSchemaAndView(schemaName: string, objectName: string): string;
159
+ }
160
+
161
+ function isQuotingProvider(provider: IMetadataProvider): provider is IMetadataProvider & QuotingProvider {
162
+ const p = provider as IMetadataProvider & Partial<QuotingProvider>;
163
+ return typeof p.QuoteIdentifier === 'function' && typeof p.QuoteSchemaAndView === 'function';
164
+ }
165
+
166
+ /**
167
+ * Filter clause that keeps only details with at least one finished run.
168
+ *
169
+ * Details with no run, or whose run is still executing, are never closed here. Without this clause
170
+ * they fill the oldest-first window, and once 200 of them accumulate no closable orphan is reached.
171
+ * The newest-run check in {@link loadTerminalRuns} still decides each remaining detail.
172
+ *
173
+ * @returns An ` AND ...` clause, or an empty string when the provider cannot quote names.
174
+ */
175
+ function hasTerminalRunClause(provider: IMetadataProvider): string {
176
+ const runs = provider.EntityByName('MJ: AI Agent Runs');
177
+ if (!runs || !isQuotingProvider(provider)) {
178
+ return '';
179
+ }
180
+ const statuses = TERMINAL_RUN_STATUSES.map(s => `'${s}'`).join(',');
181
+ return (
182
+ ` AND ID IN (SELECT ${provider.QuoteIdentifier('ConversationDetailID')}` +
183
+ ` FROM ${provider.QuoteSchemaAndView(runs.SchemaName, runs.BaseView)}` +
184
+ ` WHERE ${provider.QuoteIdentifier('Status')} IN (${statuses}))`
185
+ );
186
+ }
187
+
188
+ /**
189
+ * Newest terminal run per conversation detail. Details whose newest run is still executing are
190
+ * deliberately absent, so a retried message is judged on its latest attempt rather than an older
191
+ * finished one.
192
+ */
193
+ async function loadTerminalRuns(
194
+ rv: RunView,
195
+ detailIds: string[],
196
+ contextUser: UserInfo
197
+ ): Promise<Map<string, MJAIAgentRunEntity>> {
198
+ const quoted = detailIds.map(id => `'${id.replace(/'/g, "''")}'`).join(',');
199
+ const result = await rv.RunView<MJAIAgentRunEntity>(
200
+ {
201
+ EntityName: 'MJ: AI Agent Runs',
202
+ ExtraFilter: `ConversationDetailID IN (${quoted})`,
203
+ OrderBy: '__mj_CreatedAt DESC',
204
+ ResultType: 'entity_object',
205
+ },
206
+ contextUser
207
+ );
208
+ if (!result.Success) {
209
+ return new Map();
210
+ }
211
+
212
+ const newest = new Map<string, MJAIAgentRunEntity>();
213
+ for (const run of result.Results ?? []) {
214
+ const detailId = run.ConversationDetailID;
215
+ if (!detailId || newest.has(detailId)) {
216
+ continue; // DESC order puts the newest run first
217
+ }
218
+ newest.set(detailId, run);
219
+ }
220
+
221
+ // Drop any detail whose newest attempt is still running.
222
+ for (const [detailId, run] of newest) {
223
+ if (!TERMINAL_RUN_STATUSES.includes(run.Status)) {
224
+ newest.delete(detailId);
225
+ }
226
+ }
227
+ return newest;
228
+ }
229
+
230
+ /**
231
+ * Owner `UserInfo` per conversation, resolved through {@link UserCache}. The reconciler writes as
232
+ * the owner so the conversation-detail permission gate is satisfied on its own terms.
233
+ */
234
+ async function loadConversationOwners(
235
+ rv: RunView,
236
+ conversationIds: string[],
237
+ contextUser: UserInfo
238
+ ): Promise<Map<string, UserInfo>> {
239
+ const owners = new Map<string, UserInfo>();
240
+ if (conversationIds.length === 0) {
241
+ return owners;
242
+ }
243
+ const quoted = [...new Set(conversationIds)].map(id => `'${id.replace(/'/g, "''")}'`).join(',');
244
+ const result = await rv.RunView<{ ID: string; UserID: string }>(
245
+ {
246
+ EntityName: 'MJ: Conversations',
247
+ ExtraFilter: `ID IN (${quoted})`,
248
+ Fields: ['ID', 'UserID'],
249
+ ResultType: 'simple',
250
+ },
251
+ contextUser
252
+ );
253
+ if (!result.Success) {
254
+ return owners;
255
+ }
256
+ const users = UserCache.Instance.Users;
257
+ for (const row of result.Results ?? []) {
258
+ if (!row.UserID) {
259
+ continue;
260
+ }
261
+ const user = users.find(u => UUIDsEqual(u.ID, row.UserID));
262
+ if (user) {
263
+ owners.set(row.ID, user);
264
+ }
265
+ }
266
+ return owners;
267
+ }
@@ -1,5 +1,5 @@
1
1
  import { Arg, Field, ID, ObjectType, PubSubEngine, Resolver, ResolverFilterData, Root, Subscription } from 'type-graphql';
2
- import { UUIDsEqual } from '@memberjunction/global';
2
+ import { MJGlobal, UUIDsEqual } from '@memberjunction/global';
3
3
  import { UserPayload } from '../types.js';
4
4
 
5
5
  export const PUSH_STATUS_UPDATES_TOPIC = 'PUSH_STATUS_UPDATES';
@@ -32,6 +32,89 @@ export interface PushStatusNotificationPayload {
32
32
  sessionId: string;
33
33
  /** Authenticated user the update belongs to. Server-side filter key; never sent to the client. */
34
34
  ownerUserId: string;
35
+ /**
36
+ * `MJGlobal.ProcessUUID` of the instance that first published this update. Used only for
37
+ * cross-instance echo suppression: an instance that receives its own message back from the
38
+ * message bus must drop it, or every push is delivered twice.
39
+ */
40
+ SourceServerId?: string;
41
+ }
42
+
43
+ /** Receives every locally-published status update, for fan-out to other server instances. */
44
+ export type PushStatusPublishHook = (payload: PushStatusNotificationPayload) => void;
45
+
46
+ let _publishHook: PushStatusPublishHook | undefined;
47
+
48
+ /**
49
+ * Register (or clear, with no arg) the cross-instance fan-out hook.
50
+ *
51
+ * Inversion of control, deliberately: this module must not know that Redis exists. The server
52
+ * registers the hook at startup only when a message bus is configured, so a single-instance
53
+ * deployment behaves exactly as before.
54
+ */
55
+ export function SetPushStatusPublishHook(hook?: PushStatusPublishHook): void {
56
+ _publishHook = hook;
57
+ }
58
+
59
+ /**
60
+ * Validate an update received from the cross-instance message bus.
61
+ *
62
+ * The bus carries messages from other processes, so every field is type-checked before the update
63
+ * is republished onto the local topic, where {@link StatusUpdatesFilter} compares `ownerUserId`
64
+ * with the connection's user.
65
+ *
66
+ * @param raw The JSON text taken off the bus.
67
+ * @param localServerId This process's `MJGlobal.ProcessUUID`, used to drop our own echo.
68
+ * @returns The update to republish, or `null` to drop it.
69
+ */
70
+ export function ParseReplicatedStatusUpdate(raw: string, localServerId: string): PushStatusNotificationPayload | null {
71
+ let parsed: unknown;
72
+ try {
73
+ parsed = JSON.parse(raw);
74
+ } catch {
75
+ return null;
76
+ }
77
+ if (typeof parsed !== 'object' || parsed === null) {
78
+ return null;
79
+ }
80
+
81
+ const { sessionId, ownerUserId, message, SourceServerId } = parsed as Record<string, unknown>;
82
+ const source = typeof SourceServerId === 'string' ? SourceServerId : undefined;
83
+ const body = typeof message === 'string' ? message : undefined;
84
+ if ((SourceServerId != null && source === undefined) || (message != null && body === undefined)) {
85
+ return null; // a present field of the wrong type
86
+ }
87
+ if (source === localServerId) {
88
+ return null; // our own message, echoed back
89
+ }
90
+ if (typeof sessionId !== 'string' || !sessionId || typeof ownerUserId !== 'string' || !ownerUserId) {
91
+ return null; // fail closed: an update with no valid identity can never be routed safely
92
+ }
93
+
94
+ return { sessionId, ownerUserId, message: body, SourceServerId: source };
95
+ }
96
+
97
+ /**
98
+ * Whether an update is worth sending to other server instances.
99
+ *
100
+ * Streaming content is excluded. It arrives at hundreds of messages per second and each one carries
101
+ * a full serialized agent run, so replicating it would cost far more bandwidth than the delivery it
102
+ * buys — and a token delta is worthless to a client that has already missed the ones before it.
103
+ * Progress and completion are low-rate and each is individually meaningful, which is exactly the
104
+ * kind of message that must survive landing on the wrong replica.
105
+ */
106
+ export function ShouldReplicateStatusUpdate(message?: string): boolean {
107
+ if (!message) {
108
+ return false;
109
+ }
110
+ try {
111
+ const parsed: unknown = JSON.parse(message);
112
+ const type = (parsed as { type?: unknown })?.type;
113
+ return type !== 'StreamingContent';
114
+ } catch {
115
+ // A plain-string message (not the resolvers' JSON envelope) is rare and low-rate. Replicate it.
116
+ return true;
117
+ }
35
118
  }
36
119
 
37
120
  interface PushStatusNotificationArgs {
@@ -60,8 +143,21 @@ export function PublishStatusUpdate(pubSub: PubSubEngine, params: StatusUpdatePa
60
143
  sessionId: params.sessionId,
61
144
  ownerUserId: params.ownerUserId,
62
145
  message: params.message,
146
+ SourceServerId: MJGlobal.Instance.ProcessUUID,
63
147
  };
64
148
  pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, payload);
149
+
150
+ // Fan out to other instances. Only reached by updates originating HERE: a message arriving from
151
+ // the bus is republished straight onto the local topic and never comes back through this
152
+ // function, so there is no loop to break beyond the SourceServerId check on the receiving side.
153
+ if (_publishHook && ShouldReplicateStatusUpdate(params.message)) {
154
+ try {
155
+ _publishHook(payload);
156
+ } catch {
157
+ // Fan-out is an optimization over the durable tail query. A message bus that is down must
158
+ // never break local delivery, which is the path that works for the common case.
159
+ }
160
+ }
65
161
  }
66
162
 
67
163
  /** @deprecated Use {@link PublishStatusUpdate}. */
@@ -4,6 +4,7 @@ import {
4
4
  BaseEntityEvent,
5
5
  CompositeKey,
6
6
  DatabaseProviderBase,
7
+ EntityFieldInfo,
7
8
  EntityFieldTSType,
8
9
  EntityInfo,
9
10
  EntityPermissionType,
@@ -1692,7 +1693,7 @@ export class ResolverBase {
1692
1693
  await this.TestAndSetClientOldValuesToDBValues(input, clientNewValues, entityObject, userInfo);
1693
1694
  } else {
1694
1695
  // no OldValues, so we can just set the new values from input
1695
- entityObject.SetMany(input);
1696
+ entityObject.SetMany(clientNewValues);
1696
1697
  }
1697
1698
  } else {
1698
1699
  // Use a generic message to avoid leaking whether a record exists — distinguishing
@@ -1704,8 +1705,11 @@ export class ResolverBase {
1704
1705
  } else {
1705
1706
  // we get here if we are NOT tracking changes and we DO have OldValues, so we can load from them
1706
1707
  const oldValues = {};
1707
- // for each item in the oldValues array, add it to the oldValues object
1708
- input.OldValues___?.forEach((item) => (oldValues[item.Key] = item.Value));
1708
+ // for each item in the oldValues array, add it to the oldValues object, typed like the field
1709
+ input.OldValues___?.forEach((item) => {
1710
+ const field = entityObject.EntityInfo.Fields.find((f) => f.CodeName === item.Key);
1711
+ oldValues[item.Key] = field ? this.ClientOldValueToFieldValue(field, item.Value) : item.Value;
1712
+ });
1709
1713
 
1710
1714
  // 1) load the old values, this will be the initial state of the object
1711
1715
  await entityObject.LoadFromData(oldValues);
@@ -1772,6 +1776,11 @@ export class ResolverBase {
1772
1776
  *
1773
1777
  * Ordered so the boolean flag is evaluated last: the extra load lands only on entities that have
1774
1778
  * the feature switched on, which is almost none of them.
1779
+ *
1780
+ * `MJ: Record Changes` always loads from the database: its server class allows an update only when
1781
+ * Comments is the one dirty field, and "dirty" compared against client-supplied OldValues lets a
1782
+ * caller pin forged audit columns as both old and new values. It doesn't track its own changes, so
1783
+ * nothing else forces the load.
1775
1784
  */
1776
1785
  protected MustLoadTruthFromDatabase(
1777
1786
  entityInfo: EntityInfo,
@@ -1781,6 +1790,7 @@ export class ResolverBase {
1781
1790
  ): boolean {
1782
1791
  return (
1783
1792
  entityInfo.TrackRecordChanges ||
1793
+ entityInfo.Name.trim().toLowerCase() === 'mj: record changes' ||
1784
1794
  !input.OldValues___ ||
1785
1795
  hasDeniedReadFields ||
1786
1796
  hasNarrowedAuditPayload ||
@@ -1917,6 +1927,41 @@ export class ResolverBase {
1917
1927
  return true;
1918
1928
  }
1919
1929
 
1930
+ /**
1931
+ * Converts one client-sent old value (always a string on the wire; dates as epoch milliseconds,
1932
+ * the GraphQL Timestamp form) to the field's TypeScript type, so it compares equal to the stored
1933
+ * value. Both the OldValues comparison and the load-from-OldValues path use it: without it a date
1934
+ * old value becomes an Invalid Date and an unchanged date field reads as edited.
1935
+ */
1936
+ protected ClientOldValueToFieldValue(field: EntityFieldInfo | undefined, raw: unknown): string | number | boolean | Date | null {
1937
+ let val: unknown = raw;
1938
+ if ((val === null || val === undefined) && field && field.DefaultValue !== null && field.DefaultValue !== undefined && !field.AllowsNull)
1939
+ val = field.DefaultValue; // set default value as the field was never set and it does NOT allow nulls
1940
+ if (val === undefined) val = null;
1941
+ // A null old value stays null, except a boolean, which the comparison has always read as false.
1942
+ if (field?.TSType === EntityFieldTSType.Boolean && val === null) return false;
1943
+ if (val === null) return null;
1944
+ if (!field) {
1945
+ // No field metadata to convert by: pass scalars through, stringify anything else.
1946
+ return typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean' || val instanceof Date ? val : String(val);
1947
+ }
1948
+
1949
+ const text = String(val);
1950
+ switch (field.TSType) {
1951
+ case EntityFieldTSType.Number: {
1952
+ const integer = ['int', 'smallint', 'bigint', 'tinyint'].includes((field.Type as string).toLowerCase());
1953
+ return integer ? parseInt(text) : parseFloat(text);
1954
+ }
1955
+ case EntityFieldTSType.Boolean:
1956
+ return !(text === 'false' || text === '0' || parseInt(text) === 0);
1957
+ case EntityFieldTSType.Date:
1958
+ // Epoch milliseconds (the GraphQL Timestamp form) arrive as a numeric string.
1959
+ return new Date(text.trim() !== '' && !isNaN(Number(text)) ? parseInt(text) : text);
1960
+ default:
1961
+ return text; // already a string
1962
+ }
1963
+ }
1964
+
1920
1965
  /**
1921
1966
  * This routine compares the OldValues property in the input object to the values in the DB that we just loaded. If there are differences, we need to check to see if the client
1922
1967
  * is trying to update any of those fields (e.g. overlap). If there is overlap, we throw an error. If there is no overlap, we can proceed with the update even if the DB Values
@@ -1931,52 +1976,7 @@ export class ResolverBase {
1931
1976
  input.OldValues___.forEach((item) => {
1932
1977
  // we need to do a quick transform on the values to make sure they match the TS Type for the given field because item.Value will always be a string
1933
1978
  const field = entityObject.EntityInfo.Fields.find((f) => f.CodeName === item.Key);
1934
- let val = item.Value;
1935
- if ((val === null || val === undefined) && field.DefaultValue !== null && field.DefaultValue !== undefined && !field.AllowsNull)
1936
- val = field.DefaultValue; // set default value as the field was never set and it does NOT allow nulls
1937
-
1938
- if (field) {
1939
- switch (field.TSType) {
1940
- case EntityFieldTSType.Number:
1941
- if (val == null && val == undefined) {
1942
- val = null;
1943
- }
1944
- else {
1945
- let typeLowered = (field.Type as string).toLowerCase();
1946
-
1947
- switch (typeLowered) {
1948
- case 'int':
1949
- case 'smallint':
1950
- case 'bigint':
1951
- case 'tinyint':
1952
- val = parseInt(val);
1953
- break;
1954
- case 'money':
1955
- case 'smallmoney':
1956
- case 'decimal':
1957
- case 'numeric':
1958
- case 'float':
1959
- val = parseFloat(val);
1960
- break;
1961
- default:
1962
- val = parseFloat(val);
1963
- break;
1964
- }
1965
- }
1966
- break;
1967
- case EntityFieldTSType.Boolean:
1968
- val = val === null || val === undefined || val === 'false' || val === '0' || parseInt(val) === 0 ? false : true;
1969
- break;
1970
- case EntityFieldTSType.Date:
1971
- // first, if val is a string and it is actually a number (milliseconds since epoch), convert it to a number.
1972
- if (val !== null && val !== undefined && val.toString().trim() !== '' && !isNaN(val)) val = parseInt(val);
1973
-
1974
- val = val !== null && val !== undefined ? new Date(val) : null;
1975
- break;
1976
- default:
1977
- break; // already a string
1978
- }
1979
- }
1979
+ const val = this.ClientOldValueToFieldValue(field, item.Value);
1980
1980
  clientOldValues[item.Key] = val;
1981
1981
  });
1982
1982