@memberjunction/server 2.127.0 → 2.129.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.
- package/dist/agents/skip-sdk.d.ts.map +1 -1
- package/dist/agents/skip-sdk.js +5 -1
- package/dist/agents/skip-sdk.js.map +1 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +2 -3
- package/dist/auth/index.js.map +1 -1
- package/dist/config.d.ts +33 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +5 -0
- package/dist/config.js.map +1 -1
- package/dist/generated/generated.d.ts +9533 -9130
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +49272 -46753
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +4 -2
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +122 -10
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts +22 -0
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +171 -0
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -4
- package/dist/index.js.map +1 -1
- package/dist/resolvers/AskSkipResolver.d.ts.map +1 -1
- package/dist/resolvers/AskSkipResolver.js +5 -1
- package/dist/resolvers/AskSkipResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts +37 -0
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +304 -2
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/SyncDataResolver.js +5 -5
- package/dist/resolvers/SyncDataResolver.js.map +1 -1
- package/dist/resolvers/TelemetryResolver.d.ts +120 -0
- package/dist/resolvers/TelemetryResolver.d.ts.map +1 -0
- package/dist/resolvers/TelemetryResolver.js +731 -0
- package/dist/resolvers/TelemetryResolver.js.map +1 -0
- package/dist/services/TaskOrchestrator.d.ts.map +1 -1
- package/dist/services/TaskOrchestrator.js.map +1 -1
- package/package.json +43 -42
- package/src/agents/skip-sdk.ts +5 -1
- package/src/auth/index.ts +2 -3
- package/src/config.ts +9 -0
- package/src/generated/generated.ts +28162 -26567
- package/src/generic/ResolverBase.ts +201 -12
- package/src/generic/RunViewResolver.ts +157 -3
- package/src/index.ts +25 -5
- package/src/resolvers/AskSkipResolver.ts +18 -20
- package/src/resolvers/QueryResolver.ts +276 -2
- package/src/resolvers/RunAIAgentResolver.ts +2 -2
- package/src/resolvers/RunAIPromptResolver.ts +1 -1
- package/src/resolvers/SyncDataResolver.ts +5 -5
- package/src/resolvers/TelemetryResolver.ts +567 -0
- package/src/services/TaskOrchestrator.ts +2 -1
|
@@ -26,7 +26,8 @@ import { httpTransport, CloudEvent, emitterFor } from 'cloudevents';
|
|
|
26
26
|
import { RunViewGenericParams, UserPayload } from '../types.js';
|
|
27
27
|
import { RunDynamicViewInput, RunViewByIDInput, RunViewByNameInput } from './RunViewResolver.js';
|
|
28
28
|
import { DeleteOptionsInput } from './DeleteOptionsInput.js';
|
|
29
|
-
import { MJEvent, MJEventType, MJGlobal } from '@memberjunction/global';
|
|
29
|
+
import { MJEvent, MJEventType, MJGlobal, ENCRYPTED_SENTINEL, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
|
|
30
|
+
import { EncryptionEngine } from '@memberjunction/encryption';
|
|
30
31
|
import { PUSH_STATUS_UPDATES_TOPIC } from './PushStatusResolver.js';
|
|
31
32
|
import { FieldMapper } from '@memberjunction/graphql-dataprovider';
|
|
32
33
|
import { Subscription } from 'rxjs';
|
|
@@ -47,7 +48,20 @@ export class ResolverBase {
|
|
|
47
48
|
return g[ResolverBase._eventSubscriptionKey];
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Maps field names to their GraphQL-safe CodeNames and handles encryption for API responses.
|
|
53
|
+
*
|
|
54
|
+
* For encrypted fields coming from raw SQL queries (not entity objects):
|
|
55
|
+
* - AllowDecryptInAPI=true: Decrypt the value before sending to client
|
|
56
|
+
* - AllowDecryptInAPI=false + SendEncryptedValue=true: Keep encrypted ciphertext
|
|
57
|
+
* - AllowDecryptInAPI=false + SendEncryptedValue=false: Replace with sentinel
|
|
58
|
+
*
|
|
59
|
+
* @param entityName - The entity name
|
|
60
|
+
* @param dataObject - The data object with field values
|
|
61
|
+
* @param contextUser - Optional user context for decryption (required for encrypted fields)
|
|
62
|
+
* @returns The processed data object
|
|
63
|
+
*/
|
|
64
|
+
protected async MapFieldNamesToCodeNames(entityName: string, dataObject: any, contextUser?: UserInfo): Promise<any> {
|
|
51
65
|
// for the given entity name provided, check to see if there are any fields
|
|
52
66
|
// where the code name is different from the field name, and for just those
|
|
53
67
|
// fields, iterate through the dataObject and REPLACE the property that has the field name
|
|
@@ -69,17 +83,153 @@ export class ResolverBase {
|
|
|
69
83
|
}
|
|
70
84
|
}
|
|
71
85
|
});
|
|
86
|
+
|
|
87
|
+
// Handle encrypted fields - data from raw SQL queries is still encrypted
|
|
88
|
+
const encryptedFields = entityInfo.EncryptedFields;
|
|
89
|
+
if (encryptedFields.length > 0) {
|
|
90
|
+
for (const field of encryptedFields) {
|
|
91
|
+
const fieldName = field.CodeName;
|
|
92
|
+
const value = dataObject[fieldName];
|
|
93
|
+
|
|
94
|
+
// Skip null/undefined values
|
|
95
|
+
if (value === null || value === undefined) continue;
|
|
96
|
+
|
|
97
|
+
// Check if value is encrypted (raw SQL returns encrypted values)
|
|
98
|
+
const engine = EncryptionEngine.Instance;
|
|
99
|
+
await engine.Config(false, contextUser);
|
|
100
|
+
const keyMarker = field.EncryptionKeyID ? engine.GetKeyByID(field.EncryptionKeyID)?.Marker : undefined;
|
|
101
|
+
if (typeof value === 'string' && IsValueEncrypted(value, keyMarker)) {
|
|
102
|
+
if (field.AllowDecryptInAPI) {
|
|
103
|
+
// Decrypt the value for the client
|
|
104
|
+
if (contextUser) {
|
|
105
|
+
try {
|
|
106
|
+
const decryptedValue = await engine.Decrypt(value, contextUser);
|
|
107
|
+
dataObject[fieldName] = decryptedValue;
|
|
108
|
+
} catch (err) {
|
|
109
|
+
LogError(`Failed to decrypt field ${fieldName} for API response: ${err}`);
|
|
110
|
+
// On decryption failure, use sentinel for safety
|
|
111
|
+
dataObject[fieldName] = ENCRYPTED_SENTINEL;
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
// No context user, can't decrypt - use sentinel
|
|
115
|
+
dataObject[fieldName] = ENCRYPTED_SENTINEL;
|
|
116
|
+
}
|
|
117
|
+
} else if (!field.SendEncryptedValue) {
|
|
118
|
+
// AllowDecryptInAPI=false and SendEncryptedValue=false - use sentinel
|
|
119
|
+
dataObject[fieldName] = ENCRYPTED_SENTINEL;
|
|
120
|
+
}
|
|
121
|
+
// else: AllowDecryptInAPI=false and SendEncryptedValue=true - keep encrypted value as-is
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
72
125
|
}
|
|
73
126
|
return dataObject;
|
|
74
127
|
}
|
|
75
128
|
|
|
76
|
-
protected ArrayMapFieldNamesToCodeNames(entityName: string, dataObjectArray: any[]) {
|
|
129
|
+
protected async ArrayMapFieldNamesToCodeNames(entityName: string, dataObjectArray: any[], contextUser?: UserInfo): Promise<any[]> {
|
|
77
130
|
// iterate through the array and call MapFieldNamesToCodeNames for each element
|
|
78
131
|
if (dataObjectArray && dataObjectArray.length > 0) {
|
|
79
|
-
|
|
80
|
-
this.MapFieldNamesToCodeNames(entityName, element);
|
|
81
|
-
}
|
|
132
|
+
for (const element of dataObjectArray) {
|
|
133
|
+
await this.MapFieldNamesToCodeNames(entityName, element, contextUser);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return dataObjectArray;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Filters encrypted field values before sending to the API client.
|
|
141
|
+
*
|
|
142
|
+
* For each encrypted field in the entity:
|
|
143
|
+
* - If AllowDecryptInAPI is true: value passes through unchanged (already decrypted by data provider)
|
|
144
|
+
* - If AllowDecryptInAPI is false and SendEncryptedValue is true: re-encrypt and send ciphertext
|
|
145
|
+
* - If AllowDecryptInAPI is false and SendEncryptedValue is false: replace with sentinel value
|
|
146
|
+
*
|
|
147
|
+
* @param entityName - Name of the entity
|
|
148
|
+
* @param dataObject - The data object containing field values
|
|
149
|
+
* @param encryptionEngine - Optional encryption engine for re-encryption (lazy loaded if needed)
|
|
150
|
+
* @param contextUser - User context for encryption operations
|
|
151
|
+
* @returns The filtered data object
|
|
152
|
+
*/
|
|
153
|
+
protected async FilterEncryptedFieldsForAPI(
|
|
154
|
+
entityName: string,
|
|
155
|
+
dataObject: Record<string, unknown>,
|
|
156
|
+
contextUser: UserInfo
|
|
157
|
+
): Promise<Record<string, unknown>> {
|
|
158
|
+
if (!dataObject) return dataObject;
|
|
159
|
+
|
|
160
|
+
const md = new Metadata();
|
|
161
|
+
const entityInfo = md.Entities.find((e) => e.Name === entityName);
|
|
162
|
+
if (!entityInfo) return dataObject;
|
|
163
|
+
|
|
164
|
+
// Find all encrypted fields that need filtering
|
|
165
|
+
const encryptedFields = entityInfo.EncryptedFields;
|
|
166
|
+
if (encryptedFields.length === 0) return dataObject;
|
|
167
|
+
|
|
168
|
+
// Process each encrypted field
|
|
169
|
+
for (const field of encryptedFields) {
|
|
170
|
+
const fieldName = field.CodeName;
|
|
171
|
+
const value = dataObject[fieldName];
|
|
172
|
+
|
|
173
|
+
// Skip null/undefined values
|
|
174
|
+
if (value === null || value === undefined) continue;
|
|
175
|
+
|
|
176
|
+
// If AllowDecryptInAPI is true, the decrypted value passes through
|
|
177
|
+
if (field.AllowDecryptInAPI) continue;
|
|
178
|
+
|
|
179
|
+
// AllowDecryptInAPI is false - we need to filter the value
|
|
180
|
+
const engine = EncryptionEngine.Instance;
|
|
181
|
+
await engine.Config(false, contextUser);
|
|
182
|
+
const keyMarker = field.EncryptionKeyID ? engine.GetKeyByID(field.EncryptionKeyID)?.Marker : undefined;
|
|
183
|
+
if (field.SendEncryptedValue) {
|
|
184
|
+
// Re-encrypt the value before sending
|
|
185
|
+
// Only re-encrypt if it's not already encrypted (data provider decrypted it)
|
|
186
|
+
if (typeof value === 'string' && !IsValueEncrypted(value, keyMarker)) {
|
|
187
|
+
try {
|
|
188
|
+
const encryptedValue = await engine.Encrypt(
|
|
189
|
+
value,
|
|
190
|
+
field.EncryptionKeyID as string,
|
|
191
|
+
contextUser
|
|
192
|
+
);
|
|
193
|
+
dataObject[fieldName] = encryptedValue;
|
|
194
|
+
} catch (err) {
|
|
195
|
+
// If re-encryption fails, use sentinel for safety
|
|
196
|
+
LogError(`Failed to re-encrypt field ${fieldName} for API response: ${err}`);
|
|
197
|
+
dataObject[fieldName] = ENCRYPTED_SENTINEL;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// If already encrypted (shouldn't happen normally), keep as-is
|
|
201
|
+
} else {
|
|
202
|
+
// SendEncryptedValue is false - replace with sentinel
|
|
203
|
+
dataObject[fieldName] = ENCRYPTED_SENTINEL;
|
|
204
|
+
}
|
|
82
205
|
}
|
|
206
|
+
|
|
207
|
+
return dataObject;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Filters encrypted fields for an array of data objects
|
|
212
|
+
*/
|
|
213
|
+
protected async ArrayFilterEncryptedFieldsForAPI(
|
|
214
|
+
entityName: string,
|
|
215
|
+
dataObjectArray: Record<string, unknown>[],
|
|
216
|
+
contextUser: UserInfo
|
|
217
|
+
): Promise<Record<string, unknown>[]> {
|
|
218
|
+
if (!dataObjectArray || dataObjectArray.length === 0) return dataObjectArray;
|
|
219
|
+
|
|
220
|
+
// Check if entity has any encrypted fields first to avoid unnecessary processing
|
|
221
|
+
const md = new Metadata();
|
|
222
|
+
const entityInfo = md.Entities.find((e) => e.Name === entityName);
|
|
223
|
+
if (!entityInfo) return dataObjectArray;
|
|
224
|
+
|
|
225
|
+
const encryptedFields = entityInfo.Fields.filter(f => f.Encrypt && !f.AllowDecryptInAPI);
|
|
226
|
+
if (encryptedFields.length === 0) return dataObjectArray;
|
|
227
|
+
|
|
228
|
+
// Process each element
|
|
229
|
+
for (const element of dataObjectArray) {
|
|
230
|
+
await this.FilterEncryptedFieldsForAPI(entityName, element, contextUser);
|
|
231
|
+
}
|
|
232
|
+
|
|
83
233
|
return dataObjectArray;
|
|
84
234
|
}
|
|
85
235
|
|
|
@@ -438,8 +588,14 @@ export class ResolverBase {
|
|
|
438
588
|
for (const r of result.Results) {
|
|
439
589
|
mapper.MapFields(r);
|
|
440
590
|
}
|
|
591
|
+
// Filter encrypted fields before sending to API client
|
|
592
|
+
await this.ArrayFilterEncryptedFieldsForAPI(
|
|
593
|
+
viewInfo.Entity,
|
|
594
|
+
result.Results as Record<string, unknown>[],
|
|
595
|
+
user
|
|
596
|
+
);
|
|
441
597
|
}
|
|
442
|
-
|
|
598
|
+
|
|
443
599
|
return result;
|
|
444
600
|
} catch (err) {
|
|
445
601
|
// Fix #9: Improved error handling with structured logging
|
|
@@ -534,11 +690,22 @@ export class ResolverBase {
|
|
|
534
690
|
|
|
535
691
|
// Process results
|
|
536
692
|
const mapper = new FieldMapper();
|
|
537
|
-
for (
|
|
693
|
+
for (let i = 0; i < runViewResults.length; i++) {
|
|
694
|
+
const runViewResult = runViewResults[i];
|
|
538
695
|
if (runViewResult?.Success && runViewResult.Results?.length) {
|
|
539
696
|
for (const result of runViewResult.Results) {
|
|
540
697
|
mapper.MapFields(result);
|
|
541
698
|
}
|
|
699
|
+
// Filter encrypted fields before sending to API client
|
|
700
|
+
// Use the corresponding param's entity name
|
|
701
|
+
const entityName = params[i]?.viewInfo?.Entity;
|
|
702
|
+
if (entityName && contextUser) {
|
|
703
|
+
await this.ArrayFilterEncryptedFieldsForAPI(
|
|
704
|
+
entityName,
|
|
705
|
+
runViewResult.Results as Record<string, unknown>[],
|
|
706
|
+
contextUser
|
|
707
|
+
);
|
|
708
|
+
}
|
|
542
709
|
}
|
|
543
710
|
}
|
|
544
711
|
|
|
@@ -720,7 +887,9 @@ export class ResolverBase {
|
|
|
720
887
|
if (await entityObject.Save()) {
|
|
721
888
|
// save worked, fire the AfterCreate event and then return all the data
|
|
722
889
|
await this.AfterCreate(provider, input); // fire event
|
|
723
|
-
|
|
890
|
+
const contextUser = this.GetUserFromPayload(userPayload);
|
|
891
|
+
// MapFieldNamesToCodeNames now handles encryption filtering as well
|
|
892
|
+
return await this.MapFieldNamesToCodeNames(entityName, entityObject.GetAll(), contextUser);
|
|
724
893
|
}
|
|
725
894
|
// save failed, return null
|
|
726
895
|
else throw entityObject.LatestResult?.Message;
|
|
@@ -790,8 +959,9 @@ export class ResolverBase {
|
|
|
790
959
|
if (await entityObject.Save()) {
|
|
791
960
|
// save worked, fire afterevent and return all the data
|
|
792
961
|
await this.AfterUpdate(provider, input); // fire event
|
|
793
|
-
|
|
794
|
-
|
|
962
|
+
|
|
963
|
+
// MapFieldNamesToCodeNames now handles encryption filtering as well
|
|
964
|
+
return await this.MapFieldNamesToCodeNames(entityName, entityObject.GetAll(), userInfo);
|
|
795
965
|
} else {
|
|
796
966
|
throw new GraphQLError(entityObject.LatestResult?.Message ?? 'Unknown error', {
|
|
797
967
|
extensions: { code: 'SAVE_ENTITY_ERROR', entityName },
|
|
@@ -884,7 +1054,26 @@ export class ResolverBase {
|
|
|
884
1054
|
break;
|
|
885
1055
|
case 'object':
|
|
886
1056
|
if (clientOldValues[key] instanceof Date) {
|
|
887
|
-
|
|
1057
|
+
const clientDate = clientOldValues[key] as Date;
|
|
1058
|
+
const dbDate = dbValues[key];
|
|
1059
|
+
if (dbDate == null || !(dbDate instanceof Date)) {
|
|
1060
|
+
different = true;
|
|
1061
|
+
} else if (clientDate.getTime() !== dbDate.getTime()) {
|
|
1062
|
+
// Check if this is a datetime/datetime2 field with only a timezone hour shift
|
|
1063
|
+
const sqlType = f?.SQLFullType?.trim().toLowerCase() || '';
|
|
1064
|
+
if (sqlType !== 'datetimeoffset' && IsOnlyTimezoneShift(clientDate, dbDate)) {
|
|
1065
|
+
console.warn(
|
|
1066
|
+
`Timezone hour shift detected on field "${key}" (${sqlType || 'datetime'}). ` +
|
|
1067
|
+
`Client: ${clientDate.toISOString()}, DB: ${dbDate.toISOString()}. ` +
|
|
1068
|
+
`Consider using datetimeoffset to avoid timezone ambiguity.`
|
|
1069
|
+
);
|
|
1070
|
+
different = false; // Allow timezone shifts through for non-datetimeoffset fields
|
|
1071
|
+
} else {
|
|
1072
|
+
different = true;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
} else if (clientOldValues[key] === null) {
|
|
1076
|
+
different = dbValues[key] !== null;
|
|
888
1077
|
}
|
|
889
1078
|
break;
|
|
890
1079
|
default:
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { Arg, Ctx, Field, InputType, Int, ObjectType, PubSubEngine, Query, Resolver } from 'type-graphql';
|
|
2
2
|
import { AppContext } from '../types.js';
|
|
3
3
|
import { ResolverBase } from './ResolverBase.js';
|
|
4
|
-
import { LogError, LogStatus, EntityInfo } from '@memberjunction/core';
|
|
4
|
+
import { LogError, LogStatus, EntityInfo, RunViewWithCacheCheckResult, RunViewsWithCacheCheckResponse, RunViewWithCacheCheckParams } from '@memberjunction/core';
|
|
5
5
|
import { RequireSystemUser } from '../directives/RequireSystemUser.js';
|
|
6
6
|
import { GetReadOnlyProvider } from '../util.js';
|
|
7
7
|
import { UserViewEntityExtended } from '@memberjunction/core-entities';
|
|
8
8
|
import { KeyValuePairOutputType } from './KeyInputOutputTypes.js';
|
|
9
|
+
import { SQLServerDataProvider } from '@memberjunction/sqlserver-dataprovider';
|
|
9
10
|
|
|
10
11
|
/********************************************************************************
|
|
11
12
|
* The PURPOSE of this resolver is to provide a generic way to run a view and return the results.
|
|
@@ -383,10 +384,75 @@ export class RunViewGenericInput {
|
|
|
383
384
|
StartRow?: number;
|
|
384
385
|
}
|
|
385
386
|
|
|
387
|
+
//****************************************************************************
|
|
388
|
+
// INPUT/OUTPUT TYPES for RunViewsWithCacheCheck
|
|
389
|
+
//****************************************************************************
|
|
390
|
+
|
|
391
|
+
@InputType()
|
|
392
|
+
export class RunViewCacheStatusInput {
|
|
393
|
+
@Field(() => String, { description: 'The maximum __mj_UpdatedAt value from cached results' })
|
|
394
|
+
maxUpdatedAt: string;
|
|
395
|
+
|
|
396
|
+
@Field(() => Int, { description: 'The number of rows in cached results' })
|
|
397
|
+
rowCount: number;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
@InputType()
|
|
401
|
+
export class RunViewWithCacheCheckInput {
|
|
402
|
+
@Field(() => RunDynamicViewInput, { description: 'The RunView parameters' })
|
|
403
|
+
params: RunDynamicViewInput;
|
|
404
|
+
|
|
405
|
+
@Field(() => RunViewCacheStatusInput, {
|
|
406
|
+
nullable: true,
|
|
407
|
+
description: 'Optional cache status - if provided, server will check if cache is current'
|
|
408
|
+
})
|
|
409
|
+
cacheStatus?: RunViewCacheStatusInput;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
@ObjectType()
|
|
413
|
+
export class RunViewWithCacheCheckResultOutput {
|
|
414
|
+
@Field(() => Int, { description: 'The index of this view in the batch request' })
|
|
415
|
+
viewIndex: number;
|
|
416
|
+
|
|
417
|
+
@Field(() => String, { description: "'current', 'stale', or 'error'" })
|
|
418
|
+
status: string;
|
|
419
|
+
|
|
420
|
+
@Field(() => [RunViewGenericResultRow], {
|
|
421
|
+
nullable: true,
|
|
422
|
+
description: 'Fresh results - only populated when status is stale'
|
|
423
|
+
})
|
|
424
|
+
Results?: RunViewGenericResultRow[];
|
|
425
|
+
|
|
426
|
+
@Field(() => String, { nullable: true, description: 'Max __mj_UpdatedAt from results when stale' })
|
|
427
|
+
maxUpdatedAt?: string;
|
|
428
|
+
|
|
429
|
+
@Field(() => Int, { nullable: true, description: 'Row count of results when stale' })
|
|
430
|
+
rowCount?: number;
|
|
431
|
+
|
|
432
|
+
@Field(() => String, { nullable: true, description: 'Error message if status is error' })
|
|
433
|
+
errorMessage?: string;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
@ObjectType()
|
|
437
|
+
export class RunViewsWithCacheCheckOutput {
|
|
438
|
+
@Field(() => Boolean, { description: 'Whether the overall operation succeeded' })
|
|
439
|
+
success: boolean;
|
|
440
|
+
|
|
441
|
+
@Field(() => [RunViewWithCacheCheckResultOutput], { description: 'Results for each view in the batch' })
|
|
442
|
+
results: RunViewWithCacheCheckResultOutput[];
|
|
443
|
+
|
|
444
|
+
@Field(() => String, { nullable: true, description: 'Overall error message if success is false' })
|
|
445
|
+
errorMessage?: string;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
//****************************************************************************
|
|
449
|
+
// OUTPUT TYPES for RunView Results
|
|
450
|
+
//****************************************************************************
|
|
451
|
+
|
|
386
452
|
@ObjectType()
|
|
387
453
|
export class RunViewResultRow {
|
|
388
|
-
@Field(() => [KeyValuePairOutputType], {
|
|
389
|
-
description: 'Primary key values for the record'
|
|
454
|
+
@Field(() => [KeyValuePairOutputType], {
|
|
455
|
+
description: 'Primary key values for the record'
|
|
390
456
|
})
|
|
391
457
|
PrimaryKey: KeyValuePairOutputType[];
|
|
392
458
|
|
|
@@ -771,6 +837,94 @@ export class RunViewResolver extends ResolverBase {
|
|
|
771
837
|
}
|
|
772
838
|
}
|
|
773
839
|
|
|
840
|
+
/**
|
|
841
|
+
* RunViewsWithCacheCheck - Smart cache validation for batch RunViews.
|
|
842
|
+
* For each view, if cacheStatus is provided, the server checks if the cache is current.
|
|
843
|
+
* If current, returns status='current' with no data. If stale, returns status='stale' with fresh data.
|
|
844
|
+
*/
|
|
845
|
+
@Query(() => RunViewsWithCacheCheckOutput)
|
|
846
|
+
async RunViewsWithCacheCheck(
|
|
847
|
+
@Arg('input', () => [RunViewWithCacheCheckInput]) input: RunViewWithCacheCheckInput[],
|
|
848
|
+
@Ctx() { providers, userPayload }: AppContext
|
|
849
|
+
): Promise<RunViewsWithCacheCheckOutput> {
|
|
850
|
+
try {
|
|
851
|
+
const provider = GetReadOnlyProvider(providers, { allowFallbackToReadWrite: true });
|
|
852
|
+
|
|
853
|
+
// Cast provider to SQLServerDataProvider to access RunViewsWithCacheCheck method
|
|
854
|
+
const sqlProvider = provider as unknown as SQLServerDataProvider;
|
|
855
|
+
if (!sqlProvider.RunViewsWithCacheCheck) {
|
|
856
|
+
throw new Error('Provider does not support RunViewsWithCacheCheck');
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Convert GraphQL input types to core types
|
|
860
|
+
const coreParams: RunViewWithCacheCheckParams[] = input.map(item => ({
|
|
861
|
+
params: {
|
|
862
|
+
EntityName: item.params.EntityName,
|
|
863
|
+
ExtraFilter: item.params.ExtraFilter,
|
|
864
|
+
OrderBy: item.params.OrderBy,
|
|
865
|
+
Fields: item.params.Fields,
|
|
866
|
+
UserSearchString: item.params.UserSearchString,
|
|
867
|
+
ExcludeUserViewRunID: item.params.ExcludeUserViewRunID,
|
|
868
|
+
OverrideExcludeFilter: item.params.OverrideExcludeFilter,
|
|
869
|
+
IgnoreMaxRows: item.params.IgnoreMaxRows,
|
|
870
|
+
MaxRows: item.params.MaxRows,
|
|
871
|
+
ForceAuditLog: item.params.ForceAuditLog,
|
|
872
|
+
AuditLogDescription: item.params.AuditLogDescription,
|
|
873
|
+
ResultType: (item.params.ResultType || 'simple') as 'simple' | 'entity_object' | 'count_only',
|
|
874
|
+
StartRow: item.params.StartRow,
|
|
875
|
+
},
|
|
876
|
+
cacheStatus: item.cacheStatus ? {
|
|
877
|
+
maxUpdatedAt: item.cacheStatus.maxUpdatedAt,
|
|
878
|
+
rowCount: item.cacheStatus.rowCount,
|
|
879
|
+
} : undefined,
|
|
880
|
+
}));
|
|
881
|
+
|
|
882
|
+
const response = await sqlProvider.RunViewsWithCacheCheck(coreParams, userPayload.userRecord);
|
|
883
|
+
|
|
884
|
+
// Transform results to include processed data rows
|
|
885
|
+
const transformedResults: RunViewWithCacheCheckResultOutput[] = response.results.map((result, index) => {
|
|
886
|
+
const inputItem = input[index];
|
|
887
|
+
const entity = provider.Entities.find(e => e.Name === inputItem.params.EntityName);
|
|
888
|
+
|
|
889
|
+
if (result.status === 'stale' && result.results && entity) {
|
|
890
|
+
// Process raw data into GraphQL-compatible format
|
|
891
|
+
const processedRows = this.processRawData(result.results as Record<string, unknown>[], entity.ID, entity);
|
|
892
|
+
return {
|
|
893
|
+
viewIndex: result.viewIndex,
|
|
894
|
+
status: result.status,
|
|
895
|
+
Results: processedRows,
|
|
896
|
+
maxUpdatedAt: result.maxUpdatedAt,
|
|
897
|
+
rowCount: result.rowCount,
|
|
898
|
+
errorMessage: result.errorMessage,
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
return {
|
|
903
|
+
viewIndex: result.viewIndex,
|
|
904
|
+
status: result.status,
|
|
905
|
+
Results: undefined,
|
|
906
|
+
maxUpdatedAt: result.maxUpdatedAt,
|
|
907
|
+
rowCount: result.rowCount,
|
|
908
|
+
errorMessage: result.errorMessage,
|
|
909
|
+
};
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
return {
|
|
913
|
+
success: response.success,
|
|
914
|
+
results: transformedResults,
|
|
915
|
+
errorMessage: response.errorMessage,
|
|
916
|
+
};
|
|
917
|
+
} catch (err) {
|
|
918
|
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
|
919
|
+
LogError(err);
|
|
920
|
+
return {
|
|
921
|
+
success: false,
|
|
922
|
+
results: [],
|
|
923
|
+
errorMessage,
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
774
928
|
protected processRawData(rawData: any[], entityId: string, entityInfo: EntityInfo): RunViewResultRow[] {
|
|
775
929
|
const returnResult = [];
|
|
776
930
|
for (let i = 0; i < rawData.length; i++) {
|
package/src/index.ts
CHANGED
|
@@ -69,6 +69,8 @@ LoadSendGridProvider();
|
|
|
69
69
|
|
|
70
70
|
import { ExternalChangeDetectorEngine } from '@memberjunction/external-change-detection';
|
|
71
71
|
import { ScheduledJobsService } from './services/ScheduledJobsService.js';
|
|
72
|
+
import { LocalCacheManager, StartupManager, TelemetryManager, TelemetryLevel } from '@memberjunction/core';
|
|
73
|
+
import { getSystemUser } from './auth/index.js';
|
|
72
74
|
|
|
73
75
|
const cacheRefreshInterval = configInfo.databaseSettings.metadataCacheRefreshInterval;
|
|
74
76
|
|
|
@@ -110,6 +112,7 @@ export * from './resolvers/GetDataResolver.js';
|
|
|
110
112
|
export * from './resolvers/GetDataContextDataResolver.js';
|
|
111
113
|
export * from './resolvers/TransactionGroupResolver.js';
|
|
112
114
|
export * from './resolvers/CreateQueryResolver.js';
|
|
115
|
+
export * from './resolvers/TelemetryResolver.js';
|
|
113
116
|
export { GetReadOnlyDataSource, GetReadWriteDataSource, GetReadWriteProvider, GetReadOnlyProvider } from './util.js';
|
|
114
117
|
|
|
115
118
|
export * from './generated/generated.js';
|
|
@@ -147,11 +150,6 @@ export const serve = async (resolverPaths: Array<string>, app = createApp(), opt
|
|
|
147
150
|
const setupComplete$ = new ReplaySubject(1);
|
|
148
151
|
await pool.connect();
|
|
149
152
|
|
|
150
|
-
const config = new SQLServerProviderConfigData(pool, mj_core_schema, cacheRefreshInterval);
|
|
151
|
-
await setupSQLServerClient(config); // datasource is already initialized, so we can setup the client right away
|
|
152
|
-
const md = new Metadata();
|
|
153
|
-
console.log(`Data Source has been initialized. ${md?.Entities ? md.Entities.length : 0} entities loaded.`);
|
|
154
|
-
|
|
155
153
|
const dataSources = [new DataSourceInfo({dataSource: pool, type: 'Read-Write', host: dbHost, port: dbPort, database: dbDatabase, userName: dbUsername})];
|
|
156
154
|
|
|
157
155
|
// Establish a second read-only connection to the database if dbReadOnlyUsername and dbReadOnlyPassword exist
|
|
@@ -170,6 +168,28 @@ export const serve = async (resolverPaths: Array<string>, app = createApp(), opt
|
|
|
170
168
|
console.log('Read-only Connection Pool has been initialized.');
|
|
171
169
|
}
|
|
172
170
|
|
|
171
|
+
const config = new SQLServerProviderConfigData(pool, mj_core_schema, cacheRefreshInterval);
|
|
172
|
+
await setupSQLServerClient(config); // datasource is already initialized, so we can setup the client right away
|
|
173
|
+
const md = new Metadata();
|
|
174
|
+
console.log(`Data Source has been initialized. ${md?.Entities ? md.Entities.length : 0} entities loaded.`);
|
|
175
|
+
|
|
176
|
+
// Initialize server telemetry based on config
|
|
177
|
+
const tm = TelemetryManager.Instance;
|
|
178
|
+
if (configInfo.telemetry?.enabled) {
|
|
179
|
+
tm.SetEnabled(true);
|
|
180
|
+
if (configInfo.telemetry?.level) {
|
|
181
|
+
tm.UpdateSettings({ level: configInfo.telemetry.level as TelemetryLevel });
|
|
182
|
+
}
|
|
183
|
+
console.log(`Server telemetry enabled with level: ${configInfo.telemetry.level || 'standard'}`);
|
|
184
|
+
} else {
|
|
185
|
+
tm.SetEnabled(false);
|
|
186
|
+
console.log('Server telemetry disabled');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Initialize LocalCacheManager with the server-side storage provider (in-memory)
|
|
190
|
+
await LocalCacheManager.Instance.Initialize(Metadata.Provider.LocalStorageProvider);
|
|
191
|
+
console.log('LocalCacheManager initialized');
|
|
192
|
+
|
|
173
193
|
setupComplete$.next(true);
|
|
174
194
|
raiseEvent('setupComplete', dataSources, null, this);
|
|
175
195
|
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import { Arg, Ctx, Field,
|
|
1
|
+
import { Arg, Ctx, Field, ObjectType, PubSub, PubSubEngine, Query, Resolver } from 'type-graphql';
|
|
2
2
|
import { LogError, LogStatus, Metadata, RunView, UserInfo, CompositeKey, EntityFieldInfo, EntityInfo, EntityRelationshipInfo, EntitySaveOptions, EntityDeleteOptions, IMetadataProvider } from '@memberjunction/core';
|
|
3
|
-
import { AppContext, UserPayload
|
|
3
|
+
import { AppContext, UserPayload } from '../types.js';
|
|
4
4
|
import { BehaviorSubject } from 'rxjs';
|
|
5
5
|
import { UserCache } from '@memberjunction/sqlserver-dataprovider';
|
|
6
6
|
import { DataContext } from '@memberjunction/data-context';
|
|
7
|
-
import { LoadDataContextItemsServer } from '@memberjunction/data-context-server';
|
|
8
|
-
import { LearningCycleScheduler } from '../scheduler/LearningCycleScheduler.js';
|
|
7
|
+
import { LoadDataContextItemsServer } from '@memberjunction/data-context-server';
|
|
9
8
|
LoadDataContextItemsServer(); // prevent tree shaking since the DataContextItemServer class is not directly referenced in this file or otherwise statically instantiated, so it could be removed by the build process
|
|
10
9
|
|
|
11
10
|
import {
|
|
@@ -16,8 +15,7 @@ import {
|
|
|
16
15
|
SkipAPIDataRequestResponse,
|
|
17
16
|
SkipAPIClarifyingQuestionResponse,
|
|
18
17
|
SkipEntityInfo,
|
|
19
|
-
SkipQueryInfo,
|
|
20
|
-
SkipQueryEntityInfo,
|
|
18
|
+
SkipQueryInfo,
|
|
21
19
|
SkipAPIRunScriptRequest,
|
|
22
20
|
SkipAPIRequestAPIKey,
|
|
23
21
|
SkipRequestPhase,
|
|
@@ -26,13 +24,10 @@ import {
|
|
|
26
24
|
SkipEntityFieldInfo,
|
|
27
25
|
SkipEntityRelationshipInfo,
|
|
28
26
|
SkipEntityFieldValueInfo,
|
|
29
|
-
SkipAPILearningCycleRequest,
|
|
30
|
-
SkipAPILearningCycleResponse,
|
|
31
|
-
SkipLearningCycleNoteChange,
|
|
27
|
+
SkipAPILearningCycleRequest,
|
|
32
28
|
SkipConversation,
|
|
33
29
|
SkipAPIArtifact,
|
|
34
|
-
SkipAPIAgentRequest,
|
|
35
|
-
SkipAPIArtifactRequest,
|
|
30
|
+
SkipAPIAgentRequest,
|
|
36
31
|
SkipAPIArtifactType,
|
|
37
32
|
SkipAPIArtifactVersion,
|
|
38
33
|
} from '@memberjunction/skip-types';
|
|
@@ -50,10 +45,9 @@ import {
|
|
|
50
45
|
ConversationEntity,
|
|
51
46
|
DataContextEntity,
|
|
52
47
|
DataContextItemEntity,
|
|
53
|
-
UserNotificationEntity
|
|
54
|
-
AIAgentEntityExtended
|
|
48
|
+
UserNotificationEntity
|
|
55
49
|
} from '@memberjunction/core-entities';
|
|
56
|
-
import { apiKey as callbackAPIKey,
|
|
50
|
+
import { apiKey as callbackAPIKey, baseUrl, publicUrl, configInfo, graphqlPort, graphqlRootPath } from '../config.js';
|
|
57
51
|
import mssql from 'mssql';
|
|
58
52
|
|
|
59
53
|
import { registerEnumType } from 'type-graphql';
|
|
@@ -656,7 +650,7 @@ export class AskSkipResolver {
|
|
|
656
650
|
// learningCycleEntity.StartedAt = startTime;
|
|
657
651
|
|
|
658
652
|
// if (!(await learningCycleEntity.Save())) {
|
|
659
|
-
// throw new Error(`Failed to create learning cycle record: ${learningCycleEntity.LatestResult.
|
|
653
|
+
// throw new Error(`Failed to create learning cycle record: ${learningCycleEntity.LatestResult.CompleteMessage}`);
|
|
660
654
|
// }
|
|
661
655
|
|
|
662
656
|
// const learningCycleId = learningCycleEntity.ID;
|
|
@@ -676,7 +670,7 @@ export class AskSkipResolver {
|
|
|
676
670
|
// learningCycleEntity.AgentSummary = 'No new conversations to process, learning cycle skipped, but recorded for audit purposes.';
|
|
677
671
|
// learningCycleEntity.EndedAt = new Date();
|
|
678
672
|
// if (!(await learningCycleEntity.Save())) {
|
|
679
|
-
// LogError(`Failed to update learning cycle record: ${learningCycleEntity.LatestResult.
|
|
673
|
+
// LogError(`Failed to update learning cycle record: ${learningCycleEntity.LatestResult.CompleteMessage}`);
|
|
680
674
|
// }
|
|
681
675
|
// const result: SkipAPILearningCycleResponse = {
|
|
682
676
|
// success: true,
|
|
@@ -702,7 +696,7 @@ export class AskSkipResolver {
|
|
|
702
696
|
// learningCycleEntity.EndedAt = endTime;
|
|
703
697
|
|
|
704
698
|
// if (!(await learningCycleEntity.Save())) {
|
|
705
|
-
// LogError(`Failed to update learning cycle record: ${learningCycleEntity.LatestResult.
|
|
699
|
+
// LogError(`Failed to update learning cycle record: ${learningCycleEntity.LatestResult.CompleteMessage}`);
|
|
706
700
|
// }
|
|
707
701
|
|
|
708
702
|
// return response;
|
|
@@ -945,7 +939,7 @@ export class AskSkipResolver {
|
|
|
945
939
|
|
|
946
940
|
// // Save the note
|
|
947
941
|
// if (!(await noteEntity.Save())) {
|
|
948
|
-
// LogError(`Error saving AI Agent Note: ${noteEntity.LatestResult.
|
|
942
|
+
// LogError(`Error saving AI Agent Note: ${noteEntity.LatestResult.CompleteMessage}`);
|
|
949
943
|
// return false;
|
|
950
944
|
// }
|
|
951
945
|
|
|
@@ -986,7 +980,7 @@ export class AskSkipResolver {
|
|
|
986
980
|
|
|
987
981
|
// // Proceed with deletion
|
|
988
982
|
// if (!(await noteEntity.Delete())) {
|
|
989
|
-
// LogError(`Error deleting AI Agent Note: ${noteEntity.LatestResult.
|
|
983
|
+
// LogError(`Error deleting AI Agent Note: ${noteEntity.LatestResult.CompleteMessage}`);
|
|
990
984
|
// return false;
|
|
991
985
|
// }
|
|
992
986
|
|
|
@@ -1732,7 +1726,11 @@ export class AskSkipResolver {
|
|
|
1732
1726
|
QueryID: e.QueryID,
|
|
1733
1727
|
EntityID: e.EntityID,
|
|
1734
1728
|
Entity: e.Entity
|
|
1735
|
-
}))
|
|
1729
|
+
})),
|
|
1730
|
+
CacheEnabled: q.CacheEnabled,
|
|
1731
|
+
CacheMaxSize: q.CacheMaxSize,
|
|
1732
|
+
CacheTTLMinutes: q.CacheMaxSize,
|
|
1733
|
+
CacheValidationSQL: q.CacheValidationSQL
|
|
1736
1734
|
}));
|
|
1737
1735
|
}
|
|
1738
1736
|
|