@memberjunction/server 6.1.4 → 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.
- package/README.md +30 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/auth/magicLink/MagicLinkService.d.ts +17 -15
- package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.js +49 -35
- package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
- package/dist/auth/magicLink/magicLinkCore.d.ts +14 -33
- package/dist/auth/magicLink/magicLinkCore.d.ts.map +1 -1
- package/dist/auth/magicLink/magicLinkCore.js +20 -52
- package/dist/auth/magicLink/magicLinkCore.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +11 -23
- package/dist/context.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +36 -44
- package/dist/index.js.map +1 -1
- package/dist/postgresqlCompat.d.ts +8 -0
- package/dist/postgresqlCompat.d.ts.map +1 -0
- package/dist/postgresqlCompat.js +19 -0
- package/dist/postgresqlCompat.js.map +1 -0
- package/dist/postgresqlPoolSettings.d.ts +108 -0
- package/dist/postgresqlPoolSettings.d.ts.map +1 -0
- package/dist/postgresqlPoolSettings.js +134 -0
- package/dist/postgresqlPoolSettings.js.map +1 -0
- package/dist/resolvers/AdhocQueryResolver.d.ts +10 -20
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +54 -174
- package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +44 -6
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +90 -34
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +2 -1
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/adhocTimeout.d.ts +7 -0
- package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
- package/dist/resolvers/adhocTimeout.js +12 -0
- package/dist/resolvers/adhocTimeout.js.map +1 -0
- package/dist/services/TaskGraphActionRunner.d.ts +13 -8
- package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphActionRunner.js +16 -15
- package/dist/services/TaskGraphActionRunner.js.map +1 -1
- package/package.json +92 -92
- package/src/__tests__/AdhocQueryResolver.bugs.test.ts +12 -35
- package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
- package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +216 -2
- package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +129 -0
- package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
- package/src/__tests__/magicLink.test.ts +15 -76
- package/src/__tests__/magicLinkService.consume.test.ts +224 -0
- package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
- package/src/__tests__/translateBracketsToPG.test.ts +20 -0
- package/src/auth/magicLink/MagicLinkService.ts +55 -36
- package/src/auth/magicLink/magicLinkCore.ts +19 -56
- package/src/context.ts +11 -24
- package/src/index.ts +40 -45
- package/src/postgresqlCompat.ts +21 -0
- package/src/postgresqlPoolSettings.ts +213 -0
- package/src/resolvers/AdhocQueryResolver.ts +64 -194
- package/src/resolvers/RealtimeClientSessionResolver.ts +93 -35
- package/src/resolvers/RunAIAgentResolver.ts +4 -1
- package/src/resolvers/adhocTimeout.ts +12 -0
- package/src/services/TaskGraphActionRunner.ts +16 -15
- package/dist/resolvers/adhoc-query-helpers.d.ts +0 -27
- package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
- package/dist/resolvers/adhoc-query-helpers.js +0 -42
- package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
- package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
- package/src/resolvers/adhoc-query-helpers.ts +0 -50
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { Arg, Ctx, Query, Resolver, Field, Int, InputType } from 'type-graphql';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { RenderPipeline } from '@memberjunction/generic-database-provider';
|
|
2
|
+
import { LogError } from '@memberjunction/core';
|
|
3
|
+
import type { RunQueryResult } from '@memberjunction/core';
|
|
5
4
|
import { AppContext } from '../types.js';
|
|
6
|
-
import {
|
|
5
|
+
import { configInfo } from '../config.js';
|
|
6
|
+
import { GetReadOnlyProvider } from '../util.js';
|
|
7
7
|
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
8
8
|
import { IsScopeLimitedPrincipal } from '../auth/scopeLimitedPrincipal.js';
|
|
9
9
|
import { RunQueryResultType } from './QueryResolver.js';
|
|
10
|
-
import {
|
|
11
|
-
import sql from 'mssql';
|
|
10
|
+
import { ClampAdhocTimeoutSeconds } from './adhocTimeout.js';
|
|
12
11
|
|
|
13
12
|
/**
|
|
14
13
|
* Input type for executing ad-hoc SQL queries directly.
|
|
@@ -32,10 +31,15 @@ class AdhocQueryInput {
|
|
|
32
31
|
/**
|
|
33
32
|
* Resolver for executing ad-hoc (unsaved) SQL queries.
|
|
34
33
|
*
|
|
34
|
+
* The query runs through the read-only provider's own ad-hoc path (`RunQuery` with `SQL`), the
|
|
35
|
+
* same one in-process callers use, so it behaves identically on every database platform: the SQL
|
|
36
|
+
* is validated and rendered there, paged in the database with a count, run under the caller's
|
|
37
|
+
* timeout, and on PostgreSQL inside a read-only transaction that is rolled back.
|
|
38
|
+
*
|
|
35
39
|
* Security:
|
|
36
|
-
* - SQL validated
|
|
37
|
-
* -
|
|
38
|
-
* - Configurable timeout (default 30s)
|
|
40
|
+
* - SQL validated by the provider (SQLExpressionValidator, single read query) — blocks mutations
|
|
41
|
+
* - Read-only provider only (no fallback to read-write)
|
|
42
|
+
* - Configurable timeout (default 30s), enforced by the database
|
|
39
43
|
* - Requires authenticated user (standard GraphQL auth, no @RequireSystemUser)
|
|
40
44
|
* - Refuses scope-limited principals (see {@link IsScopeLimitedPrincipal}). Raw SQL bypasses
|
|
41
45
|
* RunView, entity permissions and row-level security, so the RLS scope tokens that confine a
|
|
@@ -52,201 +56,67 @@ export class AdhocQueryResolver extends ResolverBase {
|
|
|
52
56
|
@Ctx() context: AppContext
|
|
53
57
|
): Promise<RunQueryResultType> {
|
|
54
58
|
const startTime = Date.now();
|
|
55
|
-
|
|
59
|
+
// SECURITY: refuse principals whose read authority is narrower than their role grant.
|
|
60
|
+
// Ad-hoc SQL does not go through RunView, entity permissions or row-level security, so the
|
|
61
|
+
// RLS scope tokens that confine a magic-link session are never substituted. There is no
|
|
62
|
+
// filter to narrow, so the only correct answer for such a principal is no.
|
|
63
|
+
if (IsScopeLimitedPrincipal(context.userPayload?.userRecord)) {
|
|
64
|
+
return this.buildErrorResult('Ad-hoc SQL execution is not permitted for scope-limited sessions.');
|
|
65
|
+
}
|
|
66
|
+
const provider = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: false });
|
|
67
|
+
if (!provider) {
|
|
68
|
+
return this.buildErrorResult('No read-only data source available for ad-hoc query execution');
|
|
69
|
+
}
|
|
70
|
+
const contextUser = context.userPayload?.userRecord;
|
|
71
|
+
const timeoutSeconds = ClampAdhocTimeoutSeconds(input.TimeoutSeconds, configInfo.databaseSettings.requestTimeout);
|
|
72
|
+
// A negative offset must not reach paging, where it would turn paging off.
|
|
73
|
+
const startRow = Math.max(0, Number.isInteger(input.StartRow) ? input.StartRow! : 0);
|
|
56
74
|
try {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return this.buildErrorResult('Ad-hoc SQL execution is not permitted for scope-limited sessions.');
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// 2. Security: validate SQL using SQLExpressionValidator
|
|
68
|
-
const validator = SQLExpressionValidator.Instance;
|
|
69
|
-
const validation = validator.validateFullQuery(input.SQL);
|
|
70
|
-
if (!validation.valid) {
|
|
71
|
-
return this.buildErrorResult(validation.error || 'SQL validation failed');
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// 3. Get READ-ONLY data source (no fallback to read-write)
|
|
75
|
-
let readOnlyDS: sql.ConnectionPool;
|
|
76
|
-
try {
|
|
77
|
-
readOnlyDS = GetReadOnlyDataSource(context.dataSources, { allowFallbackToReadWrite: false });
|
|
78
|
-
} catch {
|
|
79
|
-
return this.buildErrorResult('No read-only data source available for ad-hoc query execution');
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// 4. Resolve platform from the read-only provider for the render pipeline.
|
|
83
|
-
let platform: DatabasePlatform = 'sqlserver';
|
|
84
|
-
try {
|
|
85
|
-
const provider = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: false });
|
|
86
|
-
if (provider?.PlatformKey) platform = provider.PlatformKey;
|
|
87
|
-
} catch {
|
|
88
|
-
// Provider not configured — keep the default platform.
|
|
89
|
-
}
|
|
90
|
-
const contextUser = context.userPayload?.userRecord;
|
|
91
|
-
|
|
92
|
-
// 5. Route the SQL through RenderPipeline so composition tokens
|
|
93
|
-
// resolve, comments and templates are processed, and the row cap
|
|
94
|
-
// is applied at the database (via TOP / LIMIT / OFFSET-FETCH).
|
|
95
|
-
//
|
|
96
|
-
// Page whenever a positive MaxRows is requested — INCLUDING the first
|
|
97
|
-
// page (StartRow 0). This yields OFFSET/FETCH data SQL *and* a
|
|
98
|
-
// COUNT(*) query so the response reports the true total row count
|
|
99
|
-
// rather than just the returned page size. It mirrors the saved-query
|
|
100
|
-
// path (GenericDatabaseProvider.InternalRunQuery → WrapWithPaging).
|
|
101
|
-
// (Previously paging was gated on StartRow > 0, so page 1 fell back to
|
|
102
|
-
// a TOP-N cap with TotalRowCount = page size — hiding the pager.)
|
|
103
|
-
// Clamp StartRow to a non-negative integer. A negative offset must not slip
|
|
104
|
-
// past the paging gate (which would then run the query with no row cap at all).
|
|
105
|
-
const startRow = Math.max(0, Number.isInteger(input.StartRow) ? input.StartRow! : 0);
|
|
106
|
-
const maxRows = input.MaxRows;
|
|
107
|
-
const usePaging = maxRows != null && Number.isInteger(maxRows) && maxRows > 0;
|
|
108
|
-
|
|
109
|
-
let dataSQL: string;
|
|
110
|
-
let countSQL: string | null = null;
|
|
111
|
-
try {
|
|
112
|
-
const rendered = RenderPipeline.Run(input.SQL, {
|
|
113
|
-
Platform: platform,
|
|
114
|
-
ContextUser: contextUser,
|
|
115
|
-
...(usePaging ? { Paging: { StartRow: startRow, MaxRows: maxRows! } } : {}),
|
|
116
|
-
});
|
|
117
|
-
dataSQL = rendered.FinalSQL;
|
|
118
|
-
countSQL = rendered.PagingResult?.CountSQL ?? null;
|
|
119
|
-
} catch (renderErr) {
|
|
120
|
-
const renderMsg = renderErr instanceof Error ? renderErr.message : String(renderErr);
|
|
121
|
-
return this.buildErrorResult(`Ad-hoc query rendering failed: ${renderMsg}`);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// 6. Execute the page (and, only when a full page needs it, the count) under
|
|
125
|
-
// a shared wall-clock deadline derived from the request's timeout budget.
|
|
126
|
-
const deadline = startTime + (input.TimeoutSeconds ?? 30) * 1000;
|
|
127
|
-
const { recordset, totalRowCount } = await this.executeDataAndCount(
|
|
128
|
-
readOnlyDS, dataSQL, countSQL, startRow, usePaging ? maxRows! : null, deadline
|
|
129
|
-
);
|
|
130
|
-
const executionTimeMs = Date.now() - startTime;
|
|
131
|
-
|
|
132
|
-
// 7. Return as RunQueryResultType
|
|
133
|
-
return {
|
|
134
|
-
QueryID: '',
|
|
135
|
-
QueryName: 'Ad-Hoc Query',
|
|
136
|
-
Success: true,
|
|
137
|
-
Results: JSON.stringify(recordset),
|
|
138
|
-
RowCount: recordset.length,
|
|
139
|
-
TotalRowCount: totalRowCount,
|
|
140
|
-
PageNumber: usePaging ? Math.floor(startRow / maxRows!) + 1 : undefined,
|
|
141
|
-
PageSize: usePaging ? maxRows! : undefined,
|
|
142
|
-
ExecutionTime: executionTimeMs,
|
|
143
|
-
ErrorMessage: ''
|
|
144
|
-
};
|
|
75
|
+
const result = await provider.RunQuery({
|
|
76
|
+
SQL: input.SQL,
|
|
77
|
+
MaxRows: input.MaxRows,
|
|
78
|
+
StartRow: startRow,
|
|
79
|
+
TimeoutSeconds: timeoutSeconds,
|
|
80
|
+
}, contextUser);
|
|
81
|
+
return this.toResultType(result, startRow, input.MaxRows, timeoutSeconds, Date.now() - startTime);
|
|
145
82
|
} catch (err: unknown) {
|
|
146
|
-
const executionTimeMs = Date.now() - startTime;
|
|
147
83
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
148
|
-
|
|
149
|
-
// Handle timeout
|
|
150
|
-
if (errorMessage.includes('timeout') || errorMessage.includes('Timeout')) {
|
|
151
|
-
return {
|
|
152
|
-
QueryID: '',
|
|
153
|
-
QueryName: 'Ad-Hoc Query',
|
|
154
|
-
Success: false,
|
|
155
|
-
Results: '[]',
|
|
156
|
-
RowCount: 0,
|
|
157
|
-
TotalRowCount: 0,
|
|
158
|
-
PageNumber: undefined,
|
|
159
|
-
PageSize: undefined,
|
|
160
|
-
ExecutionTime: executionTimeMs,
|
|
161
|
-
ErrorMessage: `Query execution exceeded ${input.TimeoutSeconds ?? 30} second timeout`
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
|
|
165
84
|
LogError(`Ad-hoc query execution failed: ${errorMessage}`);
|
|
166
|
-
return {
|
|
167
|
-
QueryID: '',
|
|
168
|
-
QueryName: 'Ad-Hoc Query',
|
|
169
|
-
Success: false,
|
|
170
|
-
Results: '[]',
|
|
171
|
-
RowCount: 0,
|
|
172
|
-
TotalRowCount: 0,
|
|
173
|
-
PageNumber: undefined,
|
|
174
|
-
PageSize: undefined,
|
|
175
|
-
ExecutionTime: executionTimeMs,
|
|
176
|
-
ErrorMessage: `Query execution failed: ${errorMessage}`
|
|
177
|
-
};
|
|
85
|
+
return this.buildErrorResult(`Query execution failed: ${errorMessage}`, Date.now() - startTime);
|
|
178
86
|
}
|
|
179
87
|
}
|
|
180
88
|
|
|
181
|
-
/**
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
* count: the exact total is `startRow + rowsReturned` (see {@link exactTotalFromPage}).
|
|
185
|
-
*
|
|
186
|
-
* The count is NON-FATAL: some queries page fine but cannot be counted — e.g.
|
|
187
|
-
* duplicate column names are legal in a result set but rejected inside the COUNT
|
|
188
|
-
* CTE wrap. A count failure must never sink the whole result, so we log it and
|
|
189
|
-
* report a lower-bound total (`startRow + rowsReturned`) and let the data render.
|
|
190
|
-
*/
|
|
191
|
-
private async executeDataAndCount(
|
|
192
|
-
ds: sql.ConnectionPool,
|
|
193
|
-
dataSQL: string,
|
|
194
|
-
countSQL: string | null,
|
|
89
|
+
/** Maps the provider's result to the GraphQL shape, naming a timeout plainly. */
|
|
90
|
+
private toResultType(
|
|
91
|
+
result: RunQueryResult,
|
|
195
92
|
startRow: number,
|
|
196
|
-
maxRows: number |
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// Full page — a COUNT(*) is required to know the true total.
|
|
209
|
-
const lowerBound = startRow + recordset.length;
|
|
210
|
-
try {
|
|
211
|
-
const countResult = await this.runSqlWithDeadline<{ TotalRowCount: number }>(ds, countSQL, deadline);
|
|
212
|
-
return { recordset, totalRowCount: resolveAdhocTotalRowCount(countResult.recordset, lowerBound) };
|
|
213
|
-
} catch (countErr) {
|
|
214
|
-
const msg = countErr instanceof Error ? countErr.message : String(countErr);
|
|
215
|
-
LogError(`Ad-hoc query row-count failed; reporting a lower-bound total (${lowerBound}). ${msg}`);
|
|
216
|
-
return { recordset, totalRowCount: lowerBound };
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Executes one SQL statement on the read-only pool, racing it against the shared
|
|
222
|
-
* wall-clock `deadline`. The timer is always cleared on completion so a settled
|
|
223
|
-
* query never leaves a dangling timeout armed.
|
|
224
|
-
*/
|
|
225
|
-
private async runSqlWithDeadline<T>(
|
|
226
|
-
ds: sql.ConnectionPool,
|
|
227
|
-
sqlText: string,
|
|
228
|
-
deadline: number,
|
|
229
|
-
): Promise<sql.IResult<T>> {
|
|
230
|
-
const remaining = deadline - Date.now();
|
|
231
|
-
if (remaining <= 0) {
|
|
232
|
-
throw new Error('Query timeout exceeded');
|
|
233
|
-
}
|
|
234
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
235
|
-
try {
|
|
236
|
-
return await Promise.race([
|
|
237
|
-
new sql.Request(ds).query<T>(sqlText),
|
|
238
|
-
new Promise<never>((_, reject) => {
|
|
239
|
-
timer = setTimeout(() => reject(new Error('Query timeout exceeded')), remaining);
|
|
240
|
-
}),
|
|
241
|
-
]);
|
|
242
|
-
} finally {
|
|
243
|
-
if (timer) {
|
|
244
|
-
clearTimeout(timer);
|
|
245
|
-
}
|
|
93
|
+
maxRows: number | undefined,
|
|
94
|
+
timeoutSeconds: number,
|
|
95
|
+
executionTimeMs: number,
|
|
96
|
+
): RunQueryResultType {
|
|
97
|
+
if (!result.Success) {
|
|
98
|
+
const message = result.ErrorMessage ?? 'Ad-hoc query failed';
|
|
99
|
+
return this.buildErrorResult(
|
|
100
|
+
/timeout/i.test(message) ? `Query execution exceeded ${timeoutSeconds} second timeout` : message,
|
|
101
|
+
executionTimeMs,
|
|
102
|
+
);
|
|
246
103
|
}
|
|
104
|
+
const paged = maxRows != null && Number.isInteger(maxRows) && maxRows > 0;
|
|
105
|
+
return {
|
|
106
|
+
QueryID: '',
|
|
107
|
+
QueryName: 'Ad-Hoc Query',
|
|
108
|
+
Success: true,
|
|
109
|
+
Results: JSON.stringify(result.Results),
|
|
110
|
+
RowCount: result.RowCount,
|
|
111
|
+
TotalRowCount: result.TotalRowCount,
|
|
112
|
+
PageNumber: paged ? Math.floor(startRow / maxRows!) + 1 : undefined,
|
|
113
|
+
PageSize: paged ? maxRows : undefined,
|
|
114
|
+
ExecutionTime: executionTimeMs,
|
|
115
|
+
ErrorMessage: ''
|
|
116
|
+
};
|
|
247
117
|
}
|
|
248
118
|
|
|
249
|
-
private buildErrorResult(errorMessage: string): RunQueryResultType {
|
|
119
|
+
private buildErrorResult(errorMessage: string, executionTimeMs = 0): RunQueryResultType {
|
|
250
120
|
return {
|
|
251
121
|
QueryID: '',
|
|
252
122
|
QueryName: 'Ad-Hoc Query',
|
|
@@ -256,7 +126,7 @@ export class AdhocQueryResolver extends ResolverBase {
|
|
|
256
126
|
TotalRowCount: 0,
|
|
257
127
|
PageNumber: undefined,
|
|
258
128
|
PageSize: undefined,
|
|
259
|
-
ExecutionTime:
|
|
129
|
+
ExecutionTime: executionTimeMs,
|
|
260
130
|
ErrorMessage: errorMessage
|
|
261
131
|
};
|
|
262
132
|
}
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
2288
|
-
(await this.createHiddenSessionAnchorDetail(session,
|
|
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,
|
|
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
|
-
|
|
2370
|
+
callerUser: UserInfo,
|
|
2335
2371
|
provider: IMetadataProvider,
|
|
2336
2372
|
): Promise<string | null> {
|
|
2337
|
-
const detail = await provider.GetEntityObject<MJConversationDetailEntity>(CONVERSATION_DETAIL_ENTITY,
|
|
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
|
|
2345
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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").
|
|
2671
|
-
//
|
|
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
|
|
2693
|
-
//
|
|
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
|
-
+ `${
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
+
}
|